diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c44adc8..6cf3868 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -25,13 +25,44 @@ jobs:
- name: Checkout
uses: actions/checkout@v5
- - name: Setup Swift 6.3.1
- uses: swift-actions/setup-swift@v3
- with:
- swift-version: "6.3.1"
+ - name: Test shared data contracts
+ run: swift test --package-path Packages/PlexData
- name: Build
- run: swift build
+ run: |
+ xcodebuild \
+ -project PlexBar.xcodeproj \
+ -scheme PlexBar \
+ -configuration Debug \
+ -destination 'platform=macOS' \
+ -derivedDataPath build/DerivedData \
+ -clonedSourcePackagesDirPath .build/SourcePackages \
+ -onlyUsePackageVersionsFromResolvedFile \
+ CODE_SIGNING_ALLOWED=NO \
+ build
- name: Test
- run: swift test
+ run: |
+ xcodebuild \
+ -project PlexBar.xcodeproj \
+ -scheme PlexBar \
+ -configuration Debug \
+ -destination 'platform=macOS' \
+ -derivedDataPath build/DerivedData \
+ -clonedSourcePackagesDirPath .build/SourcePackages \
+ -onlyUsePackageVersionsFromResolvedFile \
+ CODE_SIGNING_ALLOWED=NO \
+ test
+
+ - name: Build and test Studio
+ run: |
+ xcodebuild \
+ -project PlexBar.xcodeproj \
+ -scheme PlexBarStudio \
+ -configuration Debug \
+ -destination 'platform=macOS' \
+ -derivedDataPath build/StudioDerivedData \
+ -clonedSourcePackagesDirPath .build/SourcePackages \
+ -onlyUsePackageVersionsFromResolvedFile \
+ CODE_SIGNING_ALLOWED=NO \
+ test
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2f6b09f..97a90ad 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -43,7 +43,7 @@ jobs:
echo "RELEASE_VERSION=$VERSION" >> "$GITHUB_ENV"
echo "BUILD_NUMBER=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV"
echo "IS_PRERELEASE=$IS_PRERELEASE" >> "$GITHUB_ENV"
- echo "DMG_PATH=$RUNNER_TEMP/PlexBar-${TAG}-arm64.dmg" >> "$GITHUB_ENV"
+ echo "DMG_PATH=$RUNNER_TEMP/PlexBar-${TAG}-universal.dmg" >> "$GITHUB_ENV"
echo "release_tag=$TAG" >> "$GITHUB_OUTPUT"
echo "release_version=$VERSION" >> "$GITHUB_OUTPUT"
echo "build_number=$GITHUB_RUN_NUMBER" >> "$GITHUB_OUTPUT"
@@ -90,11 +90,6 @@ jobs:
echo "RELEASE_NOTES_PATH=$RELEASE_NOTES_PATH" >> "$GITHUB_ENV"
- - name: Setup Swift 6.3.1
- uses: swift-actions/setup-swift@v3
- with:
- swift-version: "6.3.1"
-
- name: Install create-dmg
env:
HOMEBREW_NO_AUTO_UPDATE: 1
@@ -131,11 +126,32 @@ jobs:
"$KEYCHAIN_PATH"
security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db
- - name: Build release app bundle
+ SIGNING_IDENTITY="$(
+ security find-identity -v -p codesigning "$KEYCHAIN_PATH" |
+ sed -nE 's/^[[:space:]]*[0-9]+\) [0-9A-F]+ "(Developer ID Application:.*)"$/\1/p' |
+ head -n 1
+ )"
+
+ if [[ -z "$SIGNING_IDENTITY" ]]; then
+ echo "No Developer ID Application identity was imported"
+ exit 1
+ fi
+
+ if [[ ! "$SIGNING_IDENTITY" =~ \(([A-Z0-9]{10})\)$ ]]; then
+ echo "Could not derive the Apple team identifier from the signing identity"
+ exit 1
+ fi
+
+ echo "CODE_SIGN_IDENTITY=$SIGNING_IDENTITY" >> "$GITHUB_ENV"
+ echo "CODE_SIGN_KEYCHAIN=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
+ echo "APPLE_TEAM_ID=${BASH_REMATCH[1]}" >> "$GITHUB_ENV"
+
+ - name: Build and verify release app bundle
env:
BUILD_CONFIGURATION: release
+ APP_MARKETING_VERSION: ${{ env.RELEASE_VERSION }}
APP_BUILD_VERSION: ${{ env.BUILD_NUMBER }}
- run: script/build_and_run.sh build
+ run: script/build_and_run.sh verify
- name: Validate Sparkle release configuration
run: |
@@ -173,67 +189,6 @@ jobs:
/usr/libexec/PlistBuddy -c "Print :SUVerifyUpdateBeforeExtraction" dist/PlexBar.app/Contents/Info.plist
/usr/libexec/PlistBuddy -c "Print :SUEnableAutomaticChecks" dist/PlexBar.app/Contents/Info.plist
- - name: Sign app bundle
- run: |
- APP_PATH="dist/PlexBar.app"
- SPARKLE_FRAMEWORK="$APP_PATH/Contents/Frameworks/Sparkle.framework"
- CODE_SIGN_IDENTITY="Developer ID Application"
-
- if [[ ! -d "$APP_PATH" ]]; then
- echo "App bundle not found at $APP_PATH"
- exit 1
- fi
-
- if [[ ! -d "$SPARKLE_FRAMEWORK" ]]; then
- echo "Sparkle framework not found at $SPARKLE_FRAMEWORK"
- exit 1
- fi
-
- codesign --force \
- --options runtime \
- --sign "$CODE_SIGN_IDENTITY" \
- --keychain "$KEYCHAIN_PATH" \
- --timestamp \
- "$SPARKLE_FRAMEWORK/Versions/B/XPCServices/Installer.xpc"
-
- if [[ -d "$SPARKLE_FRAMEWORK/Versions/B/XPCServices/Downloader.xpc" ]]; then
- codesign --force \
- --options runtime \
- --preserve-metadata=entitlements \
- --sign "$CODE_SIGN_IDENTITY" \
- --keychain "$KEYCHAIN_PATH" \
- --timestamp \
- "$SPARKLE_FRAMEWORK/Versions/B/XPCServices/Downloader.xpc"
- fi
-
- codesign --force \
- --options runtime \
- --sign "$CODE_SIGN_IDENTITY" \
- --keychain "$KEYCHAIN_PATH" \
- --timestamp \
- "$SPARKLE_FRAMEWORK/Versions/B/Autoupdate"
-
- codesign --force \
- --options runtime \
- --sign "$CODE_SIGN_IDENTITY" \
- --keychain "$KEYCHAIN_PATH" \
- --timestamp \
- "$SPARKLE_FRAMEWORK/Versions/B/Updater.app"
-
- codesign --force \
- --options runtime \
- --sign "$CODE_SIGN_IDENTITY" \
- --keychain "$KEYCHAIN_PATH" \
- --timestamp \
- "$SPARKLE_FRAMEWORK"
-
- codesign --force \
- --options runtime \
- --sign "$CODE_SIGN_IDENTITY" \
- --keychain "$KEYCHAIN_PATH" \
- --timestamp \
- "$APP_PATH"
-
- name: Create signed DMG
run: |
DMG_BACKGROUND_PATH=".github/release-assets/background.png"
@@ -276,7 +231,7 @@ jobs:
- name: Sign DMG
run: |
codesign --force \
- --sign "Developer ID Application" \
+ --sign "$CODE_SIGN_IDENTITY" \
--keychain "$KEYCHAIN_PATH" \
--timestamp \
"$DMG_PATH"
@@ -368,7 +323,7 @@ jobs:
env:
SPARKLE_PRIVATE_KEY_BASE64: ${{ secrets.SPARKLE_PRIVATE_KEY_BASE64 }}
run: |
- SPARKLE_BIN=".build/artifacts/sparkle/Sparkle/bin/generate_appcast"
+ SPARKLE_BIN=".build/SourcePackages/artifacts/sparkle/Sparkle/bin/generate_appcast"
APPCAST_WORK_DIR="appcast-work"
PAGES_DIR="pages"
SPARKLE_KEY_PATH="$RUNNER_TEMP/sparkle-ed25519-private-key"
diff --git a/.gitignore b/.gitignore
index 6f7988b..87510a9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,9 +2,11 @@
/.build
/build
/dist
-/Packages
+/Packages/**/.build/
+/Packages/**/.swiftpm/
xcuserdata/
DerivedData/
+*.xcresult
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
@@ -17,3 +19,6 @@ Icon[^a-zA-Z]
# docs
/docs/plans
+
+# Studio keeps unaccepted generations and local history out of version control.
+/PlexBar/Resources/MockServer/.studio/
diff --git a/AGENTS.md b/AGENTS.md
index 66ed949..16985f8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -9,17 +9,22 @@ This file defines project constraints for coding agents working in this reposito
## Platform + App Contract
-- PlexBar is a macOS-only app built with SwiftPM.
+- PlexBar is a macOS-only app built by the `PlexBar` target in `PlexBar.xcodeproj`.
- Minimum supported platform is macOS 26+.
-- The app is menu-bar-first and should remain an accessory app without a Dock icon unless otherwise explicitly requested.
+- PlexBar is one regular macOS app with a primary window and a persistent menu-bar extra. The window and menu-bar UI must share the same stores and services.
- UI work should stay SwiftUI-first.
-- Do not introduce AppKit UI implementations unless a maintainer explicitly asks.
+- Use native Apple media frameworks. `AVPlayerView` may be bridged into SwiftUI where SwiftUI has no equivalent macOS player view; do not replace native controls with a custom imitation.
+- Do not introduce web views, cross-platform UI layers, or third-party playback engines.
## Project Boundaries
-- App sources live in `Sources/PlexBar/`.
-- Tests live in `Tests/PlexBarTests/`.
+- PlexBar app sources live in `PlexBar/`; its integration tests live in `PlexBarTests/`.
+- Studio is a separate macOS app target, `PlexBarStudio`, with sources in `Studio/`, tests in `StudioTests/`, and its own README in `Studio/README.md`.
+- Shared Plex models and mock-data contracts live in the local `Packages/PlexData` package. Apps import its products; do not add cross-app source-file membership or duplicate shared types.
+- Package tests live in `Packages/PlexData/Tests/` and run with `swift test --package-path Packages/PlexData`.
+- Keep package code independent of app UI, stores, authentication, playback orchestration, and resource locations. Each app owns its resource loading.
- Keep view code in `Views/`, stateful app logic in `Stores/`, API/auth code in `Services/`, and shared helpers in `Support/`.
+- Keep playback orchestration in `Playback/`, app-specific models in `Models/`, and shared decoded Plex contracts in the package's `Sources/PlexModels/`.
## Code Expectations
@@ -60,7 +65,7 @@ This file defines project constraints for coding agents working in this reposito
From repo root (`/Users/austinsmith/Developer/Repos/PlexBar`), build with:
```bash
-swift build
+xcodebuild -project PlexBar.xcodeproj -scheme PlexBar -configuration Debug -destination 'platform=macOS' build
```
To build and launch the app bundle:
@@ -73,7 +78,7 @@ script/build_and_run.sh
- PlexBar uses Sparkle for auto-updates of Developer ID releases.
- Sparkle appcast/release workflow is documented in `docs/sparkle-updates.md`.
-- Sparkle update metadata is injected by `script/build_and_run.sh` at bundle generation time; keep it out of checked-in source plist files.
+- Sparkle update metadata is injected by Xcode from build settings supplied by `script/build_and_run.sh`.
- `script/build_and_run.sh` loads `.env.local` when present for local Sparkle build metadata.
## Testing
@@ -81,7 +86,7 @@ script/build_and_run.sh
From repo root (`/Users/austinsmith/Developer/Repos/PlexBar`), run:
```bash
-swift test
+xcodebuild -project PlexBar.xcodeproj -scheme PlexBar -configuration Debug -destination 'platform=macOS' test
```
Add tests when they protect meaningful behavior, parsing logic, or regressions. Avoid low-value tests for simple refactors or trivial helpers.
diff --git a/Config/Debug.xcconfig b/Config/Debug.xcconfig
new file mode 100644
index 0000000..b72619e
--- /dev/null
+++ b/Config/Debug.xcconfig
@@ -0,0 +1,4 @@
+#include "Shared.xcconfig"
+
+SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG
+ONLY_ACTIVE_ARCH = YES
diff --git a/Config/PlexBar-Info.plist b/Config/PlexBar-Info.plist
new file mode 100644
index 0000000..3a02433
--- /dev/null
+++ b/Config/PlexBar-Info.plist
@@ -0,0 +1,38 @@
+
+
+
+
+ CFBundleDisplayName
+ $(PRODUCT_NAME)
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIconName
+ $(ASSETCATALOG_COMPILER_APPICON_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ LSApplicationCategoryType
+ public.app-category.entertainment
+ LSMinimumSystemVersion
+ $(MACOSX_DEPLOYMENT_TARGET)
+ NSPrincipalClass
+ NSApplication
+ SUEnableAutomaticChecks
+
+ SUFeedURL
+ $(SPARKLE_APPCAST_URL)
+ SUPublicEDKey
+ $(SPARKLE_PUBLIC_KEY)
+ SUVerifyUpdateBeforeExtraction
+
+
+
diff --git a/Config/PlexBarTV-Info.plist b/Config/PlexBarTV-Info.plist
new file mode 100644
index 0000000..a799e30
--- /dev/null
+++ b/Config/PlexBarTV-Info.plist
@@ -0,0 +1,58 @@
+
+
+
+
+ CFBundleURLTypes
+
+
+ CFBundleURLName
+ com.crapshack.PlexBar.tv.topshelf
+ CFBundleURLSchemes
+ plexbar-tv
+
+
+ CFBundleDisplayName
+ PlexBar
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ LSApplicationCategoryType
+ public.app-category.entertainment
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+ NSAllowsLocalNetworking
+
+
+ NSLocalNetworkUsageDescription
+ PlexBar connects directly to your Plex Media Server to browse and play your library.
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+
+ UIBackgroundModes
+
+ audio
+
+ UILaunchScreen
+
+ UIColorName
+
+
+ UIUserInterfaceStyle
+ Dark
+
+
diff --git a/Config/PlexBarTV.entitlements b/Config/PlexBarTV.entitlements
new file mode 100644
index 0000000..482d0a7
--- /dev/null
+++ b/Config/PlexBarTV.entitlements
@@ -0,0 +1,14 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ group.com.crapshack.PlexBar.tv
+
+ keychain-access-groups
+
+ $(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)
+
+
+
diff --git a/Config/PlexBarTopShelf-Info.plist b/Config/PlexBarTopShelf-Info.plist
new file mode 100644
index 0000000..f72ea4c
--- /dev/null
+++ b/Config/PlexBarTopShelf-Info.plist
@@ -0,0 +1,29 @@
+
+
+
+
+ CFBundleDisplayName
+ PlexBar Top Shelf
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ XPC!
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.tv-top-shelf
+ NSExtensionPrincipalClass
+ $(PRODUCT_MODULE_NAME).ContentProvider
+
+
+
diff --git a/Config/PlexBarTopShelf.entitlements b/Config/PlexBarTopShelf.entitlements
new file mode 100644
index 0000000..20bedf5
--- /dev/null
+++ b/Config/PlexBarTopShelf.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ group.com.crapshack.PlexBar.tv
+
+
+
diff --git a/Config/Release.xcconfig b/Config/Release.xcconfig
new file mode 100644
index 0000000..06efff2
--- /dev/null
+++ b/Config/Release.xcconfig
@@ -0,0 +1,5 @@
+#include "Shared.xcconfig"
+
+SWIFT_COMPILATION_MODE = wholemodule
+DEAD_CODE_STRIPPING = YES
+CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO
diff --git a/Config/Shared.xcconfig b/Config/Shared.xcconfig
new file mode 100644
index 0000000..3ae5558
--- /dev/null
+++ b/Config/Shared.xcconfig
@@ -0,0 +1,14 @@
+PRODUCT_NAME = PlexBar
+PRODUCT_BUNDLE_IDENTIFIER = com.crapshack.PlexBar
+MARKETING_VERSION = 0.8.0
+CURRENT_PROJECT_VERSION = 1
+MACOSX_DEPLOYMENT_TARGET = 26.0
+SWIFT_VERSION = 6.0
+SWIFT_STRICT_CONCURRENCY = complete
+ENABLE_HARDENED_RUNTIME = YES
+ENABLE_APP_SANDBOX = NO
+CODE_SIGN_STYLE = Automatic
+GENERATE_INFOPLIST_FILE = NO
+INFOPLIST_FILE = Config/PlexBar-Info.plist
+ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon
+LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/../Frameworks
diff --git a/Package.swift b/Package.swift
deleted file mode 100644
index 72bc2f4..0000000
--- a/Package.swift
+++ /dev/null
@@ -1,39 +0,0 @@
-// swift-tools-version: 6.3
-// The swift-tools-version declares the minimum version of Swift required to build this package.
-
-import PackageDescription
-
-let package = Package(
- name: "PlexBar",
- platforms: [
- .macOS(.v26)
- ],
- products: [
- .executable(
- name: "PlexBar",
- targets: ["PlexBar"]
- )
- ],
- dependencies: [
- .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.1")
- ],
- targets: [
- .executableTarget(
- name: "PlexBar",
- dependencies: [
- .product(name: "Sparkle", package: "Sparkle")
- ],
- exclude: [
- "Resources/MockServer"
- ],
- resources: [
- .process("Resources/MenuBarIcon")
- ]
- ),
- .testTarget(
- name: "PlexBarTests",
- dependencies: ["PlexBar"]
- ),
- ],
- swiftLanguageModes: [.v6]
-)
diff --git a/Packages/PlexData/Package.swift b/Packages/PlexData/Package.swift
new file mode 100644
index 0000000..8ccb784
--- /dev/null
+++ b/Packages/PlexData/Package.swift
@@ -0,0 +1,17 @@
+// swift-tools-version: 6.2
+import PackageDescription
+
+let package = Package(
+ name: "PlexData",
+ platforms: [.macOS(.v26), .tvOS(.v26)],
+ products: [
+ .library(name: "PlexModels", targets: ["PlexModels"]),
+ .library(name: "PlexMockData", targets: ["PlexMockData"]),
+ ],
+ targets: [
+ .target(name: "PlexModels"),
+ .target(name: "PlexMockData", dependencies: ["PlexModels"]),
+ .testTarget(name: "PlexModelsTests", dependencies: ["PlexModels"]),
+ .testTarget(name: "PlexMockDataTests", dependencies: ["PlexMockData"]),
+ ]
+)
diff --git a/Packages/PlexData/README.md b/Packages/PlexData/README.md
new file mode 100644
index 0000000..cc3c7f0
--- /dev/null
+++ b/Packages/PlexData/README.md
@@ -0,0 +1,18 @@
+# PlexData
+
+Local Swift package containing the data contracts shared by the apps in this repository. It uses Foundation and Swift 6, with no app, network, authentication, or resource-bundle dependencies.
+
+- **PlexModels** owns decoded Plex media, account, library, session, and notification models, plus their decoding and text helpers. PlexBar and PlexBarTV import this module.
+- **PlexMockData** depends on PlexModels and owns mock payload decoding and catalog validation. PlexBar's mock server and Studio use the same validation contract.
+
+Both modules accept data supplied by their callers. They do not locate a checkout, load sample files, start a server, or modify resources. PlexBar owns its debug resource loader; Studio owns direct checkout access and local generation history. Playback orchestration, UI presentation, stores, and services stay in their app targets.
+
+## Tests
+
+From the repository root:
+
+```sh
+swift test --package-path Packages/PlexData
+```
+
+Tests import the public modules directly, without an app host. Session-model tests live in `Tests/PlexModelsTests`; catalog validation tests live in `Tests/PlexMockDataTests`. Integration tests using app stores, views, or sample resources remain with the corresponding app.
diff --git a/Packages/PlexData/Sources/PlexMockData/PlexMockMediaCatalog.swift b/Packages/PlexData/Sources/PlexMockData/PlexMockMediaCatalog.swift
new file mode 100644
index 0000000..4ab4ed7
--- /dev/null
+++ b/Packages/PlexData/Sources/PlexMockData/PlexMockMediaCatalog.swift
@@ -0,0 +1,140 @@
+import Foundation
+import PlexModels
+
+/// The mock keeps PMS metadata verbatim so browsing and details use the same contract.
+/// Source citations and sample-library relationships stay outside the PMS response.
+public struct PlexMockMediaCatalog: Sendable {
+ public struct Record: Sendable {
+ public let item: PlexMediaItem
+ public let metadataData: Data
+ public let sources: [URL]
+ public let addedAtSecondsAgo: Int
+ public let relatedIDs: [String]
+ public let extraIDs: [String]
+
+ public func object(referenceDate: Date) -> [String: Any] {
+ // Validated once when the catalog is loaded.
+ var object = try! JSONSerialization.jsonObject(with: metadataData) as! [String: Any]
+ object["addedAt"] = Int(referenceDate.timeIntervalSince1970) - addedAtSecondsAgo
+ return object
+ }
+ }
+
+ public let records: [Record]
+ private let recordsByID: [String: Record]
+
+ public init(data: Data) throws {
+ guard let entries = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
+ throw CatalogError.invalid("Expected an array of catalog records")
+ }
+ var records: [Record] = []
+ var recordsByID: [String: Record] = [:]
+ for entry in entries {
+ guard let metadata = entry["metadata"] as? [String: Any],
+ let sourceStrings = entry["sources"] as? [String], !sourceStrings.isEmpty,
+ let addedAtSecondsAgo = entry["addedAtSecondsAgo"] as? Int,
+ let relatedIDs = entry["relatedIDs"] as? [String],
+ let extraIDs = entry["extraIDs"] as? [String],
+ addedAtSecondsAgo >= 0 else {
+ throw CatalogError.invalid("Each record needs metadata, sources, relationship arrays, and a nonnegative age")
+ }
+ let sources = try sourceStrings.map { source in
+ guard let url = URL(string: source), url.scheme == "https", url.host != nil else {
+ throw CatalogError.invalid("Invalid source URL: \(source)")
+ }
+ return url
+ }
+ let metadataData = try JSONSerialization.data(withJSONObject: metadata)
+ let item = try JSONDecoder().decode(PlexMediaItem.self, from: metadataData)
+ guard !item.ratingKey.isEmpty, recordsByID[item.ratingKey] == nil,
+ item.type != nil, item.key != nil, metadata["title"] is String else {
+ throw CatalogError.invalid("Missing identity, type, key, title, or duplicate ID: \(item.ratingKey)")
+ }
+ guard item.media.allSatisfy({ $0.parts.isEmpty }) else {
+ throw CatalogError.invalid("The browse-only catalog must not contain playback parts")
+ }
+ let record = Record(
+ item: item,
+ metadataData: metadataData,
+ sources: sources,
+ addedAtSecondsAgo: addedAtSecondsAgo,
+ relatedIDs: relatedIDs,
+ extraIDs: extraIDs
+ )
+ records.append(record)
+ recordsByID[item.ratingKey] = record
+ }
+ self.records = records
+ self.recordsByID = recordsByID
+ try validateRelationships()
+ }
+
+ public func record(for id: String) -> Record? {
+ recordsByID[id]
+ }
+
+ public func children(of id: String) -> [Record] {
+ records.filter { $0.item.parentRatingKey == id }
+ .sorted { ($0.item.index ?? 0, $0.item.ratingKey) < ($1.item.index ?? 0, $1.item.ratingKey) }
+ }
+
+ public func leaves(of id: String) -> [Record] {
+ children(of: id).flatMap { record in
+ record.item.hasChildren ? leaves(of: record.item.ratingKey) : [record]
+ }
+ }
+
+ private func validateRelationships() throws {
+ for record in records {
+ let item = record.item
+ let references = [item.parentRatingKey, item.grandparentRatingKey].compactMap { $0 }
+ + record.relatedIDs + record.extraIDs
+ guard references.allSatisfy({ recordsByID[$0] != nil && $0 != item.ratingKey }) else {
+ throw CatalogError.invalid("Unresolved or self-referencing relationship: \(item.ratingKey)")
+ }
+ var ancestors: Set = [item.ratingKey]
+ var parent = item.parentRatingKey
+ while let parentID = parent {
+ guard ancestors.insert(parentID).inserted else {
+ throw CatalogError.invalid("Hierarchy cycle: \(item.ratingKey)")
+ }
+ parent = recordsByID[parentID]?.item.parentRatingKey
+ }
+ if let parentID = item.parentRatingKey, let parent = recordsByID[parentID]?.item {
+ let expectedParent: String? = switch item.type {
+ case "season": "show"
+ case "episode": "season"
+ case "album": "artist"
+ case "track": "album"
+ default: nil
+ }
+ if let expectedParent, parent.type != expectedParent {
+ throw CatalogError.invalid("Incorrect parent type: \(item.ratingKey)")
+ }
+ if let grandparentID = item.grandparentRatingKey, parent.parentRatingKey != grandparentID {
+ throw CatalogError.invalid("Incorrect grandparent: \(item.ratingKey)")
+ }
+ }
+ }
+ // Check counts only after cycle validation, before traversing the hierarchy.
+ for record in records where record.item.hasChildren {
+ let item = record.item
+ if let count = item.childCount, count != children(of: item.ratingKey).count {
+ throw CatalogError.invalid("Incorrect child count: \(item.ratingKey)")
+ }
+ if let count = item.leafCount, count != leaves(of: item.ratingKey).count {
+ throw CatalogError.invalid("Incorrect leaf count: \(item.ratingKey)")
+ }
+ }
+ }
+
+ public enum CatalogError: Error, LocalizedError, Sendable {
+ case invalid(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case .invalid(let message): "Invalid mock catalog: \(message)"
+ }
+ }
+ }
+}
diff --git a/Packages/PlexData/Sources/PlexMockData/PlexMockServerPayload+Validation.swift b/Packages/PlexData/Sources/PlexMockData/PlexMockServerPayload+Validation.swift
new file mode 100644
index 0000000..c35fdd4
--- /dev/null
+++ b/Packages/PlexData/Sources/PlexMockData/PlexMockServerPayload+Validation.swift
@@ -0,0 +1,73 @@
+import Foundation
+import PlexModels
+
+extension PlexMockServerPayload {
+ /// Validate authoring references before either app materializes Plex responses.
+ public func validateProfiles() throws {
+ var userIDs: Set = []
+ var deviceIDs: Set = []
+ var machineIdentifiers: Set = []
+ var locationsByIP: [String: String] = [:]
+ for user in users {
+ guard userIDs.insert(user.id).inserted else {
+ throw ProfileError("Duplicate user ID \(user.id).")
+ }
+ guard user.username.nilIfBlank != nil else {
+ throw ProfileError("User \(user.id) needs a username.")
+ }
+ for device in user.devices {
+ guard deviceIDs.insert(device.id).inserted else {
+ throw ProfileError("Duplicate device ID \(device.id). Device IDs must be unique across users.")
+ }
+ guard device.title.nilIfBlank != nil, device.machineIdentifier.nilIfBlank != nil else {
+ throw ProfileError("Device \(device.id) needs a name and machine identifier.")
+ }
+ guard machineIdentifiers.insert(device.machineIdentifier).inserted else {
+ throw ProfileError("Duplicate machine identifier \(device.machineIdentifier).")
+ }
+ if let location = device.connection.resolvedLocation?.nilIfBlank {
+ guard let ip = device.connection.remotePublicAddress?.nilIfBlank else {
+ throw ProfileError("Device \(device.id) needs a public IP address to resolve its location.")
+ }
+ if let existing = locationsByIP[ip], existing != location {
+ throw ProfileError("Public IP \(ip) has conflicting mock locations.")
+ }
+ locationsByIP[ip] = location
+ }
+ }
+ }
+ guard userIDs.contains(authenticatedUserID) else {
+ throw ProfileError("Authenticated user \(authenticatedUserID) does not exist.")
+ }
+ let artworkPaths = Set(artwork.map(\.path))
+ for user in users {
+ if let avatar = user.avatar, !artworkPaths.contains(avatar) {
+ throw ProfileError("User \(user.id) has an unregistered avatar: \(avatar).")
+ }
+ }
+ let usersByID = Dictionary(uniqueKeysWithValues: users.map { ($0.id, $0) })
+ func validateDevice(_ deviceID: Int?, userID: Int, context: String) throws {
+ guard let user = usersByID[userID] else {
+ throw ProfileError("\(context) references missing user \(userID).")
+ }
+ if let deviceID, !user.devices.contains(where: { $0.id == deviceID }) {
+ throw ProfileError("\(context) references device \(deviceID), which does not belong to user \(userID).")
+ }
+ }
+ var sessionKeys: Set = []
+ for session in activeSessions {
+ guard session.sessionKey.nilIfBlank != nil, sessionKeys.insert(session.sessionKey).inserted else {
+ throw ProfileError("Active session keys must be present and unique.")
+ }
+ try validateDevice(session.deviceID, userID: session.userID, context: "Session \(session.sessionKey)")
+ }
+ for event in historyEvents {
+ try validateDevice(event.deviceID, userID: event.userID, context: "History \(event.historyKey)")
+ }
+ }
+
+ public struct ProfileError: LocalizedError, Sendable {
+ public let errorDescription: String?
+ init(_ message: String) { errorDescription = message }
+ }
+}
diff --git a/Packages/PlexData/Sources/PlexMockData/PlexMockServerPayload.swift b/Packages/PlexData/Sources/PlexMockData/PlexMockServerPayload.swift
new file mode 100644
index 0000000..5e393e2
--- /dev/null
+++ b/Packages/PlexData/Sources/PlexMockData/PlexMockServerPayload.swift
@@ -0,0 +1,174 @@
+import Foundation
+import PlexModels
+
+public struct PlexMockServerPayload: Decodable, Sendable {
+ public let authenticatedUserID: Int
+ public let server: Server
+ public let users: [User]
+ public let activeSessions: [ActiveSession]
+ public let historyEvents: [HistoryEvent]
+ public let libraries: [Library]
+ public let artwork: [Artwork]
+
+}
+
+extension PlexMockServerPayload {
+ public struct Server: Decodable, Sendable {
+ public let id: String
+ public let name: String
+ public let productVersion: String?
+ public let accessToken: String
+ public let connections: [Connection]
+
+ public func materialize() -> PlexServerResource {
+ PlexServerResource(
+ id: id,
+ name: name,
+ productVersion: productVersion,
+ accessToken: accessToken,
+ connections: connections.map { $0.materialize() }
+ )
+ }
+ }
+
+ public struct Connection: Decodable, Sendable {
+ public let uri: URL
+ public let local: Bool
+ public let relay: Bool
+
+ public func materialize() -> PlexServerConnection {
+ PlexServerConnection(uri: uri, local: local, relay: relay)
+ }
+ }
+
+ public struct User: Codable, Equatable, Identifiable, Sendable {
+ public let id: Int
+ public var name: String { friendlyName?.nilIfBlank ?? username }
+ public var username: String
+ public var email: String?
+ public var friendlyName: String?
+ public var avatar: String?
+ public var devices: [Device]
+
+ public func materialize() -> PlexAccount {
+ PlexAccount(id: id, name: name, thumb: avatar)
+ }
+
+ public func materializeUser() -> PlexUser {
+ PlexUser(id: String(id), thumb: avatar, title: name)
+ }
+
+ public func materializeAuthenticatedUser(thumbOverride: String? = nil) -> PlexAuthenticatedUser {
+ PlexAuthenticatedUser(id: id, username: username, title: name,
+ email: email?.nilIfBlank, thumb: thumbOverride ?? avatar,
+ friendlyName: friendlyName?.nilIfBlank)
+ }
+ }
+
+ public struct Device: Codable, Equatable, Identifiable, Sendable {
+ public let id: Int
+ public var title: String
+ public var machineIdentifier: String
+ public var platform: String?
+ public var product: String?
+ public var connection: DeviceConnection
+
+ public init(id: Int, title: String, machineIdentifier: String, platform: String? = nil,
+ product: String? = nil, connection: DeviceConnection = .init()) {
+ self.id = id
+ self.title = title
+ self.machineIdentifier = machineIdentifier
+ self.platform = platform
+ self.product = product
+ self.connection = connection
+ }
+
+ public func materializePlayer(state: String?) -> PlexPlayer {
+ PlexPlayer(address: connection.address, machineIdentifier: machineIdentifier,
+ platform: platform, product: product, remotePublicAddress: connection.remotePublicAddress,
+ state: state, title: title, local: connection.local,
+ relayed: connection.relayed, secure: connection.secure)
+ }
+
+ public func materializeHistoryDevice() -> PlexHistoryDevice {
+ PlexHistoryDevice(id: id, name: title, platform: platform)
+ }
+ }
+
+ /// The connection used by this mock device. Playback state belongs to each activity record.
+ public struct DeviceConnection: Codable, Equatable, Sendable {
+ public var address: String?
+ public var remotePublicAddress: String?
+ public var resolvedLocation: String?
+ public var local: Bool?
+ public var relayed: Bool?
+ public var secure: Bool?
+
+ public init() {}
+
+ public var sessionLocation: String? {
+ local.map { $0 ? "lan" : "wan" }
+ }
+ }
+
+ public struct Artwork: Decodable, Sendable {
+ public let path: String
+ public let resource: String
+ }
+
+ public struct ActiveSession: Decodable, Sendable {
+ public let sessionKey: String
+ public let userID: Int
+ public let mediaType: String
+ public let mediaID: String
+ public let viewOffset: Int?
+ public let deviceID: Int
+ public let state: String?
+ public let session: PlaybackSession?
+ public let transcodeSession: PlexTranscodeSession?
+ public let mediaDecision: String?
+ public let mediaStreams: [PlexStream]?
+ public let audioStream: AudioStream?
+ }
+
+ public struct AudioStream: Decodable, Sendable {
+ public let id: Int
+ public let streamType: Int
+ public let codec: String?
+ public let selected: Bool?
+ public let levels: [Double]
+ }
+
+ public struct HistoryEvent: Decodable, Sendable {
+ public let historyKey: String
+ public let userID: Int
+ public let mediaType: String
+ public let mediaID: String
+ public let viewedAtSecondsAgo: Int
+ public let deviceID: Int?
+ }
+
+ public struct Library: Decodable, Sendable {
+ public let id: String
+ public let title: String
+ public let type: String
+ public let updatedAtSecondsAgo: Int?
+ public let scannedAtSecondsAgo: Int?
+ public let contentChangedAtSecondsAgo: Int?
+ public let entries: [LibraryEntry]
+ }
+
+ public struct LibraryEntry: Decodable, Sendable {
+ public let mediaID: String
+ }
+
+ public struct PlaybackSession: Decodable, Sendable {
+ public let id: String?
+ public let bandwidth: Int?
+
+ public func materialize(location: String?) -> PlexPlaybackSession {
+ PlexPlaybackSession(id: id, bandwidth: bandwidth, location: location)
+ }
+ }
+
+}
diff --git a/Packages/PlexData/Sources/PlexModels/PlexAccount.swift b/Packages/PlexData/Sources/PlexModels/PlexAccount.swift
new file mode 100644
index 0000000..9b53029
--- /dev/null
+++ b/Packages/PlexData/Sources/PlexModels/PlexAccount.swift
@@ -0,0 +1,68 @@
+import Foundation
+
+public struct PlexAccount: Decodable, Identifiable, Equatable, Sendable {
+ public let id: Int
+ public let name: String
+ public let thumb: String?
+
+ enum CodingKeys: String, CodingKey {
+ case id
+ case name
+ case thumb
+ }
+
+ public init(
+ id: Int,
+ name: String,
+ thumb: String? = nil
+ ) {
+ self.id = id
+ self.name = name
+ self.thumb = thumb
+ }
+}
+
+public struct PlexHistoryDevice: Decodable, Identifiable, Equatable, Sendable {
+ public let id: Int
+ public let name: String
+ public let platform: String?
+
+ public var displayLine: String? {
+ let values = [name.nilIfBlank, platform?.nilIfBlank]
+ .compactMap { $0 }
+ .reduce(into: [String]()) { result, value in
+ guard
+ !result.contains(where: {
+ $0.localizedCaseInsensitiveCompare(value) == .orderedSame
+ })
+ else {
+ return
+ }
+ result.append(value)
+ }
+ return values.isEmpty ? nil : values.joined(separator: " · ")
+ }
+
+ public init(
+ id: Int,
+ name: String,
+ platform: String? = nil
+ ) {
+ self.id = id
+ self.name = name
+ self.platform = platform
+ }
+}
+
+public struct PlexHistoryIdentityDirectory: Equatable, Sendable {
+ public let accounts: [PlexAccount]
+ public let devices: [PlexHistoryDevice]
+
+ public init(
+ accounts: [PlexAccount],
+ devices: [PlexHistoryDevice]
+ ) {
+ self.accounts = accounts
+ self.devices = devices
+ }
+}
diff --git a/Packages/PlexData/Sources/PlexModels/PlexAuthenticatedUser.swift b/Packages/PlexData/Sources/PlexModels/PlexAuthenticatedUser.swift
new file mode 100644
index 0000000..dc8e483
--- /dev/null
+++ b/Packages/PlexData/Sources/PlexModels/PlexAuthenticatedUser.swift
@@ -0,0 +1,209 @@
+import Foundation
+
+public struct PlexAuthenticatedUser: Decodable, Equatable, Identifiable, Sendable {
+ public let id: Int
+ public let username: String
+ public let title: String?
+ public let email: String?
+ public let thumb: String?
+ public let friendlyName: String?
+ public let subscription: PlexAccountSubscription?
+ public let subscriptions: [PlexUserSubscription]
+ public let roles: [String]
+ public let entitlements: [String]
+
+ enum CodingKeys: String, CodingKey {
+ case id
+ case username
+ case title
+ case email
+ case thumb
+ case friendlyName
+ case subscription
+ case subscriptions
+ case roles
+ case entitlements
+ }
+
+ public init(
+ id: Int,
+ username: String,
+ title: String?,
+ email: String?,
+ thumb: String?,
+ friendlyName: String?,
+ subscription: PlexAccountSubscription? = nil,
+ subscriptions: [PlexUserSubscription] = [],
+ roles: [String] = [],
+ entitlements: [String] = []
+ ) {
+ self.id = id
+ self.username = username
+ self.title = title
+ self.email = email
+ self.thumb = thumb
+ self.friendlyName = friendlyName
+ self.subscription = subscription
+ self.subscriptions = subscriptions
+ self.roles = roles
+ self.entitlements = entitlements
+ }
+
+ public init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ id = try values.decode(Int.self, forKey: .id)
+ username = try values.decode(String.self, forKey: .username)
+ title = try values.decodeIfPresent(String.self, forKey: .title)
+ email = try values.decodeIfPresent(String.self, forKey: .email)
+ thumb = try values.decodeIfPresent(String.self, forKey: .thumb)
+ friendlyName = try values.decodeIfPresent(String.self, forKey: .friendlyName)
+ subscription = try values.decodeIfPresent(PlexAccountSubscription.self, forKey: .subscription)
+ subscriptions = try values.decodeIfPresent(
+ PlexUserSubscriptions.self,
+ forKey: .subscriptions
+ )?.subscription ?? []
+ roles = values.decodePlexStringListIfPresent(forKey: .roles) ?? []
+ entitlements = values.decodePlexStringListIfPresent(forKey: .entitlements) ?? []
+ }
+
+ public var displayName: String {
+ title?.nilIfBlank ?? username
+ }
+
+ public var displayEmail: String? {
+ email?.nilIfBlank
+ }
+
+ public var displayUsername: String? {
+ let normalizedUsername = username.nilIfBlank
+ guard let normalizedUsername,
+ normalizedUsername != displayName else {
+ return nil
+ }
+
+ return normalizedUsername
+ }
+
+ public var hasPlexPass: Bool {
+ subscription?.isEffectivePlexPass == true
+ || subscriptions.contains(where: \.isEffectivePlexPass)
+ || roles.contains(where: { $0.caseInsensitiveCompare("plexpass") == .orderedSame })
+ }
+
+ public var hasDownloadsAccountEntitlement: Bool {
+ let capabilities = Set(
+ (subscription?.features ?? []) + roles + entitlements
+ ).map { $0.lowercased() }
+
+ return hasPlexPass
+ || capabilities.contains("sync")
+ || capabilities.contains("grandfather-sync")
+ }
+}
+
+public struct PlexAccountSubscription: Decodable, Equatable, Sendable {
+ public let active: Bool?
+ public let status: String?
+ public let plan: String?
+ public let features: [String]
+
+ private enum CodingKeys: String, CodingKey {
+ case active
+ case status
+ case plan
+ case features
+ }
+
+ public init(
+ active: Bool?,
+ status: String?,
+ plan: String?,
+ features: [String]
+ ) {
+ self.active = active
+ self.status = status
+ self.plan = plan
+ self.features = features
+ }
+
+ public init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ active = values.decodePlexBoolIfPresent(forKey: .active)
+ status = try values.decodeIfPresent(String.self, forKey: .status)
+ plan = try values.decodeIfPresent(String.self, forKey: .plan)
+ features = values.decodePlexStringListIfPresent(forKey: .features) ?? []
+ }
+
+ public var isEffectivePlexPass: Bool {
+ guard active == true else { return false }
+ if let normalizedStatus = status?.nilIfBlank?.lowercased(),
+ normalizedStatus != "active",
+ normalizedStatus != "pending_cancellation" {
+ return false
+ }
+
+ let normalizedFeatures = Set(features.map { $0.lowercased() })
+ return normalizedFeatures.contains("plexpass") || normalizedFeatures.contains("pass")
+ }
+}
+
+public struct PlexUserSubscriptions: Decodable, Equatable, Sendable {
+ public let subscription: [PlexUserSubscription]
+
+ public init(subscription: [PlexUserSubscription]) {
+ self.subscription = subscription
+ }
+
+ public init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ subscription = try values.decodeIfPresent(
+ [PlexUserSubscription].self,
+ forKey: .subscription
+ ) ?? []
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case subscription
+ }
+}
+
+public struct PlexUserSubscription: Decodable, Equatable, Sendable {
+ public let type: String
+ public let state: String
+ public let mode: String?
+ public let active: Bool?
+ public let subscribedAt: String?
+
+ public var isEffectivePlexPass: Bool {
+ type == "plexpass" && (state == "active" || state == "pending_cancellation")
+ }
+
+ public init(
+ type: String,
+ state: String,
+ mode: String? = nil,
+ active: Bool? = nil,
+ subscribedAt: String? = nil
+ ) {
+ self.type = type
+ self.state = state
+ self.mode = mode
+ self.active = active
+ self.subscribedAt = subscribedAt
+ }
+}
+
+private extension KeyedDecodingContainer {
+ func decodePlexStringListIfPresent(forKey key: Key) -> [String]? {
+ if let values = try? decodeIfPresent([String].self, forKey: key) {
+ return values
+ }
+ if let value = try? decodeIfPresent(String.self, forKey: key) {
+ return value
+ .split(separator: ",")
+ .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+ }
+ return nil
+ }
+}
diff --git a/Packages/PlexData/Sources/PlexModels/PlexDecoding.swift b/Packages/PlexData/Sources/PlexModels/PlexDecoding.swift
new file mode 100644
index 0000000..b782ce4
--- /dev/null
+++ b/Packages/PlexData/Sources/PlexModels/PlexDecoding.swift
@@ -0,0 +1,69 @@
+import Foundation
+
+extension KeyedDecodingContainer {
+ public func decodePlexString(forKey key: Key) throws -> String {
+ if let value = try? decode(String.self, forKey: key) {
+ return value
+ }
+ if let value = try? decode(Int.self, forKey: key) {
+ return String(value)
+ }
+ throw DecodingError.keyNotFound(
+ key,
+ DecodingError.Context(
+ codingPath: codingPath,
+ debugDescription: "Expected a Plex string or integer value."
+ )
+ )
+ }
+
+ public func decodePlexStringIfPresent(forKey key: Key) -> String? {
+ if let value = try? decodeIfPresent(String.self, forKey: key) {
+ return value
+ }
+ if let value = try? decodeIfPresent(Int.self, forKey: key) {
+ return String(value)
+ }
+ return nil
+ }
+
+ public func decodePlexIntIfPresent(forKey key: Key) -> Int? {
+ if let value = try? decodeIfPresent(Int.self, forKey: key) {
+ return value
+ }
+ if let value = try? decodeIfPresent(String.self, forKey: key) {
+ return Int(value)
+ }
+ return nil
+ }
+
+ public func decodePlexInt64IfPresent(forKey key: Key) -> Int64? {
+ if let value = try? decodeIfPresent(Int64.self, forKey: key) {
+ return value
+ }
+ if let value = try? decodeIfPresent(String.self, forKey: key) {
+ return Int64(value)
+ }
+ return nil
+ }
+
+ public func decodePlexDoubleIfPresent(forKey key: Key) -> Double? {
+ if let value = try? decodeIfPresent(Double.self, forKey: key) {
+ return value
+ }
+ if let value = try? decodeIfPresent(String.self, forKey: key) {
+ return Double(value)
+ }
+ return nil
+ }
+
+ public func decodePlexBoolIfPresent(forKey key: Key) -> Bool? {
+ if let value = try? decodeIfPresent(Bool.self, forKey: key) {
+ return value
+ }
+ if let value = decodePlexIntIfPresent(forKey: key) {
+ return value != 0
+ }
+ return nil
+ }
+}
diff --git a/Packages/PlexData/Sources/PlexModels/PlexEpisodeText.swift b/Packages/PlexData/Sources/PlexModels/PlexEpisodeText.swift
new file mode 100644
index 0000000..f8a7ca2
--- /dev/null
+++ b/Packages/PlexData/Sources/PlexModels/PlexEpisodeText.swift
@@ -0,0 +1,16 @@
+import Foundation
+
+public enum PlexEpisodeText: Sendable {
+ public static func numbers(season: Int?, episode: Int?) -> String? {
+ [season.map { "S\($0)" }, episode.map { "E\($0)" }]
+ .compactMap { $0 }
+ .joined(separator: " • ")
+ .nilIfBlank
+ }
+
+ public static func subtitle(season: Int?, episode: Int?, title: String) -> String {
+ [numbers(season: season, episode: episode), title.nilIfBlank]
+ .compactMap { $0 }
+ .joined(separator: " - ")
+ }
+}
diff --git a/Sources/PlexBar/Models/PlexLibrary.swift b/Packages/PlexData/Sources/PlexModels/PlexLibrary.swift
similarity index 61%
rename from Sources/PlexBar/Models/PlexLibrary.swift
rename to Packages/PlexData/Sources/PlexModels/PlexLibrary.swift
index 25322eb..fddce62 100644
--- a/Sources/PlexBar/Models/PlexLibrary.swift
+++ b/Packages/PlexData/Sources/PlexModels/PlexLibrary.swift
@@ -1,6 +1,6 @@
import Foundation
-enum PlexMediaIcon: Equatable {
+public enum PlexMediaIcon: Equatable, Sendable {
case movie
case show
case music
@@ -10,7 +10,7 @@ enum PlexMediaIcon: Equatable {
case other
case library
- var symbolName: String {
+ public var symbolName: String {
switch self {
case .movie:
"film"
@@ -32,27 +32,28 @@ enum PlexMediaIcon: Equatable {
}
}
-struct PlexLibrary: Identifiable, Equatable {
- let id: String
- let title: String
- let type: PlexLibraryType
- let compositePath: String?
- let artPath: String?
- let thumbPath: String?
- let itemCount: Int
- let secondaryCount: Int?
- let secondaryCountLabel: String?
- let updatedAt: Date?
- let scannedAt: Date?
- let contentChangedAt: Date?
- let latestAddedAt: Date?
- let latestItemTitle: String?
-
- var sortDate: Date {
+public struct PlexLibrary: Identifiable, Equatable, Sendable {
+ public let id: String
+ public let title: String
+ public let type: PlexLibraryType
+ public let compositePath: String?
+ public let artPath: String?
+ public let thumbPath: String?
+ public let itemCount: Int
+ public let secondaryCount: Int?
+ public let secondaryCountLabel: String?
+ public let updatedAt: Date?
+ public let scannedAt: Date?
+ public let contentChangedAt: Date?
+ public let latestAddedAt: Date?
+ public let latestItemTitle: String?
+ public var allowSync: Bool? = nil
+
+ public var sortDate: Date {
latestAddedAt ?? contentChangedAt ?? scannedAt ?? updatedAt ?? .distantPast
}
- var itemSummary: String {
+ public var itemSummary: String {
guard let secondaryCount, let secondaryCountLabel else {
return "\(itemCount.formatted()) \(type.itemLabel(for: itemCount))"
}
@@ -62,15 +63,15 @@ struct PlexLibrary: Identifiable, Equatable {
return "\(primaryLabel) • \(secondaryLabel)"
}
- var primaryItemSummary: String {
+ public var primaryItemSummary: String {
"\(itemCount.formatted()) \(type.itemLabel(for: itemCount))"
}
- var statusDate: Date? {
+ public var statusDate: Date? {
latestAddedAt ?? contentChangedAt ?? scannedAt ?? updatedAt
}
- var statusPrefix: String {
+ public var statusPrefix: String {
if latestAddedAt != nil {
return "Latest add"
}
@@ -85,9 +86,43 @@ struct PlexLibrary: Identifiable, Equatable {
return "Updated"
}
+
+ public init(
+ id: String,
+ title: String,
+ type: PlexLibraryType,
+ compositePath: String? = nil,
+ artPath: String? = nil,
+ thumbPath: String? = nil,
+ itemCount: Int,
+ secondaryCount: Int? = nil,
+ secondaryCountLabel: String? = nil,
+ updatedAt: Date? = nil,
+ scannedAt: Date? = nil,
+ contentChangedAt: Date? = nil,
+ latestAddedAt: Date? = nil,
+ latestItemTitle: String? = nil,
+ allowSync: Bool? = nil
+ ) {
+ self.id = id
+ self.title = title
+ self.type = type
+ self.compositePath = compositePath
+ self.artPath = artPath
+ self.thumbPath = thumbPath
+ self.itemCount = itemCount
+ self.secondaryCount = secondaryCount
+ self.secondaryCountLabel = secondaryCountLabel
+ self.updatedAt = updatedAt
+ self.scannedAt = scannedAt
+ self.contentChangedAt = contentChangedAt
+ self.latestAddedAt = latestAddedAt
+ self.latestItemTitle = latestItemTitle
+ self.allowSync = allowSync
+ }
}
-enum PlexLibraryType: Equatable {
+public enum PlexLibraryType: Equatable, Sendable {
case movie
case show
case artist
@@ -97,7 +132,7 @@ enum PlexLibraryType: Equatable {
case clip
case unknown(String)
- init(rawValue: String) {
+ public init(rawValue: String) {
switch rawValue.lowercased() {
case "movie":
self = .movie
@@ -118,7 +153,7 @@ enum PlexLibraryType: Equatable {
}
}
- var displayName: String {
+ public var displayName: String {
switch self {
case .movie:
"Movies"
@@ -139,7 +174,7 @@ enum PlexLibraryType: Equatable {
}
}
- var symbolName: String {
+ public var symbolName: String {
icon.symbolName
}
@@ -160,7 +195,7 @@ enum PlexLibraryType: Equatable {
}
}
- func itemLabel(for count: Int) -> String {
+ public func itemLabel(for count: Int) -> String {
switch self {
case .movie:
count == 1 ? "movie" : "movies"
@@ -181,7 +216,7 @@ enum PlexLibraryType: Equatable {
}
}
- var preferredSecondarySummary: (queryType: Int, label: String)? {
+ public var preferredSecondarySummary: (queryType: Int, label: String)? {
switch self {
case .show:
return (3, "seasons")
diff --git a/Packages/PlexData/Sources/PlexModels/PlexMedia.swift b/Packages/PlexData/Sources/PlexModels/PlexMedia.swift
new file mode 100644
index 0000000..6921293
--- /dev/null
+++ b/Packages/PlexData/Sources/PlexModels/PlexMedia.swift
@@ -0,0 +1,726 @@
+import Foundation
+
+public struct PlexMediaItem: Decodable, Equatable, Hashable, Identifiable, Sendable {
+ public let ratingKey: String
+ public let key: String?
+ public let guid: String?
+ public let type: String?
+ public let subtype: String?
+ public let title: String
+ public let originalTitle: String?
+ public let reason: String?
+ public let reasonTitle: String?
+ public let reasonID: String?
+ public let parentRatingKey: String?
+ public let grandparentRatingKey: String?
+ public let librarySectionID: String?
+ public let librarySectionTitle: String?
+ public let parentTitle: String?
+ public let grandparentTitle: String?
+ public let year: Int?
+ public let index: Int?
+ public let parentIndex: Int?
+ public let duration: Int?
+ public let summary: String?
+ public let thumb: String?
+ public let composite: String?
+ public let parentThumb: String?
+ public let grandparentThumb: String?
+ public let art: String?
+ public let images: [PlexMediaImage]
+ public let studio: String?
+ public let contentRating: String?
+ public let originallyAvailableAt: String?
+ public let rating: Double?
+ public let ratingImage: String?
+ public let audienceRating: Double?
+ public let audienceRatingImage: String?
+ public let guids: [PlexMediaGUID]
+ public let ratings: [PlexMediaRating]
+ public var userRating: Double?
+ public var viewOffset: Int?
+ public var viewCount: Int?
+ public let leafCount: Int?
+ public var viewedLeafCount: Int?
+ public let childCount: Int?
+ public let skipChildren: Bool?
+ public let skipParent: Bool?
+ public let primaryExtraKey: String?
+ public let playQueueItemID: String?
+ public let playlistItemID: String?
+ public let playlistType: String?
+ public let smart: Bool?
+ public let readOnly: Bool?
+ public let media: [PlexMediaVersion]
+ public let genres: [PlexTag]
+ public let directors: [PlexTag]
+ public let writers: [PlexTag]
+ public let producers: [PlexTag]
+ public let countries: [PlexTag]
+ public let roles: [PlexTag]
+ public let chapters: [PlexMediaChapter]
+ public let markers: [PlexMediaMarker]
+
+ public var id: String {
+ playQueueItemID.map { "play-queue-item:\($0)" }
+ ?? playlistItemID.map { "playlist-item:\($0)" }
+ ?? ratingKey
+ }
+
+ public var isPlayable: Bool {
+ supportsNativePlayback && media.contains { !$0.parts.isEmpty }
+ }
+
+ public var supportsNativePlayback: Bool {
+ type?.lowercased() != "photo"
+ }
+
+ public var hasChildren: Bool {
+ guard let type = type?.lowercased() else {
+ return false
+ }
+
+ return [
+ "show", "season", "artist", "album", "photoalbum", "collection", "playlist",
+ "playlistfolder"
+ ].contains(type)
+ }
+
+ public var childrenPath: String? {
+ guard hasChildren, let key else {
+ return nil
+ }
+
+ guard skipChildren == true,
+ var components = URLComponents(string: key),
+ components.path.hasSuffix("/children") else {
+ return key
+ }
+
+ components.path = String(components.path.dropLast("children".count)) + "grandchildren"
+ return components.string
+ }
+
+ public func hierarchyRequestItem(afterRefreshingWith details: PlexMediaItem) -> PlexMediaItem {
+ guard ratingKey == details.ratingKey,
+ childrenPath == nil,
+ details.childrenPath != nil else {
+ return self
+ }
+ return details
+ }
+
+ public var progress: Double? {
+ guard let duration, duration > 0, let viewOffset else {
+ return nil
+ }
+
+ return min(max(Double(viewOffset) / Double(duration), 0), 1)
+ }
+
+ public var isWatched: Bool {
+ if let leafCount, leafCount > 0, let viewedLeafCount {
+ return viewedLeafCount >= leafCount
+ }
+ return (viewCount ?? 0) > 0
+ }
+
+ public var supportsWatchedStateMutation: Bool {
+ guard let type = type?.lowercased() else {
+ return false
+ }
+ return ["movie", "show", "season", "episode"].contains(type)
+ }
+
+ public var supportsPlaybackHistory: Bool {
+ guard Int(ratingKey) != nil, let type = type?.lowercased() else {
+ return false
+ }
+
+ return ["movie", "show", "season", "episode", "artist", "album", "track"].contains(type)
+ }
+
+ public func mergingWatchedState(from refreshedItem: PlexMediaItem) -> PlexMediaItem {
+ guard ratingKey == refreshedItem.ratingKey else {
+ return self
+ }
+
+ var merged = self
+ merged.viewOffset = refreshedItem.viewOffset
+ merged.viewCount = refreshedItem.viewCount
+ merged.viewedLeafCount = refreshedItem.viewedLeafCount
+ return merged
+ }
+
+ public func mergingUserRating(from refreshedItem: PlexMediaItem) -> PlexMediaItem {
+ guard ratingKey == refreshedItem.ratingKey else {
+ return self
+ }
+
+ var merged = self
+ merged.userRating = refreshedItem.userRating
+ return merged
+ }
+
+ public var supportsMetadataRefresh: Bool {
+ guard let type = type?.lowercased() else {
+ return false
+ }
+ return [
+ "movie", "show", "season", "episode", "artist", "album", "track", "photo", "photoalbum"
+ ].contains(type)
+ }
+
+ public var preferredArtworkPath: String? {
+ thumb?.nilIfBlank ?? composite?.nilIfBlank
+ }
+
+ public var formattedDuration: String? {
+ guard let duration, duration > 0 else {
+ return nil
+ }
+
+ let roundedMinutes = max((Int64(duration) + 30_000) / 60_000, 1)
+ return Duration.seconds(roundedMinutes * 60)
+ .formatted(.units(width: .abbreviated))
+ .replacingOccurrences(of: ", ", with: " ")
+ }
+
+ public var posterArtworkPath: String? {
+ switch type?.lowercased() {
+ case "episode":
+ grandparentThumb?.nilIfBlank
+ ?? parentThumb?.nilIfBlank
+ case "season":
+ grandparentThumb?.nilIfBlank
+ ?? parentThumb?.nilIfBlank
+ ?? preferredArtworkPath
+ default:
+ preferredArtworkPath
+ }
+ }
+
+ public var nowPlayingArtworkPaths: [String] {
+ let candidates: [String?] = switch type?.lowercased() {
+ case "episode":
+ [grandparentThumb, parentThumb]
+ case "season":
+ [grandparentThumb, parentThumb, thumb, art]
+ case "track":
+ [parentThumb, grandparentThumb, thumb, art]
+ default:
+ [thumb, parentThumb, grandparentThumb, art]
+ }
+
+ return uniqueArtworkPaths(candidates)
+ }
+
+ public var contentProposalArtworkPaths: [String] {
+ uniqueArtworkPaths([thumb, art, parentThumb, grandparentThumb])
+ }
+
+ private func uniqueArtworkPaths(_ candidates: [String?]) -> [String] {
+ var seen: Set = []
+ return candidates.compactMap { candidate in
+ guard let path = candidate?.nilIfBlank, seen.insert(path).inserted else {
+ return nil
+ }
+ return path
+ }
+ }
+
+ public var subtitle: String? {
+ if type?.lowercased() == "clip", let extraSubtypeLabel {
+ return extraSubtypeLabel
+ }
+
+ let hierarchy = grandparentTitle ?? parentTitle
+ let detail = year.map(String.init) ?? type?.capitalized
+ let candidates = [reasonTitle, hierarchy, detail].compactMap { $0?.nilIfBlank }
+ var seen: Set = []
+ let values = candidates.filter { seen.insert($0).inserted }
+ return values.isEmpty ? nil : values.joined(separator: " · ")
+ }
+
+ public var supportsMediaExtras: Bool {
+ type?.lowercased() != "clip"
+ }
+
+ public var extraSubtypeLabel: String? {
+ guard type?.lowercased() == "clip" else {
+ return nil
+ }
+
+ return switch subtype {
+ case "trailer": "Trailer"
+ case "deletedScene": "Deleted Scene"
+ case "interview": "Interview"
+ case "musicVideo": "Music Video"
+ case "behindTheScenes": "Behind the Scenes"
+ case "sceneOrSample": "Scene or Sample"
+ case "liveMusicVideo": "Live Music Video"
+ case "lyricMusicVideo": "Lyric Music Video"
+ case "concert": "Concert"
+ case "featurette": "Featurette"
+ case "short": "Short"
+ case "other": "Other"
+ default: nil
+ }
+ }
+
+ public var primaryExtraActionTitle: String? {
+ guard primaryExtraKey?.nilIfBlank != nil else {
+ return nil
+ }
+
+ return switch type?.lowercased() {
+ case "movie": "Trailer"
+ case "track": "Music Video"
+ default: nil
+ }
+ }
+
+ public var clearLogoPath: String? {
+ images.first { $0.type.caseInsensitiveCompare("clearLogo") == .orderedSame }?
+ .url
+ .nilIfBlank
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case ratingKey
+ case key
+ case guid
+ case type
+ case subtype
+ case title
+ case originalTitle
+ case reason
+ case reasonTitle
+ case reasonID
+ case parentRatingKey
+ case grandparentRatingKey
+ case librarySectionID
+ case librarySectionTitle
+ case parentTitle
+ case grandparentTitle
+ case year
+ case index
+ case parentIndex
+ case duration
+ case summary
+ case thumb
+ case composite
+ case parentThumb
+ case grandparentThumb
+ case art
+ case images = "Image"
+ case studio
+ case contentRating
+ case originallyAvailableAt
+ case rating
+ case ratingImage
+ case audienceRating
+ case audienceRatingImage
+ case guids = "Guid"
+ case ratings = "Rating"
+ case userRating
+ case viewOffset
+ case viewCount
+ case leafCount
+ case viewedLeafCount
+ case childCount
+ case skipChildren
+ case skipParent
+ case primaryExtraKey
+ case playQueueItemID
+ case playlistItemID
+ case playlistType
+ case smart
+ case readOnly
+ case media = "Media"
+ case genres = "Genre"
+ case directors = "Director"
+ case writers = "Writer"
+ case producers = "Producer"
+ case countries = "Country"
+ case roles = "Role"
+ case chapters = "Chapter"
+ case markers = "Marker"
+ }
+
+ public init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ ratingKey = try values.decodePlexString(forKey: .ratingKey)
+ key = try values.decodeIfPresent(String.self, forKey: .key)
+ guid = try values.decodeIfPresent(String.self, forKey: .guid)
+ type = try values.decodeIfPresent(String.self, forKey: .type)
+ subtype = try values.decodeIfPresent(String.self, forKey: .subtype)
+ title = try values.decodeIfPresent(String.self, forKey: .title) ?? "Untitled"
+ originalTitle = try values.decodeIfPresent(String.self, forKey: .originalTitle)
+ reason = try values.decodeIfPresent(String.self, forKey: .reason)
+ reasonTitle = try values.decodeIfPresent(String.self, forKey: .reasonTitle)
+ reasonID = values.decodePlexStringIfPresent(forKey: .reasonID)
+ parentRatingKey = values.decodePlexStringIfPresent(forKey: .parentRatingKey)
+ grandparentRatingKey = values.decodePlexStringIfPresent(forKey: .grandparentRatingKey)
+ librarySectionID = values.decodePlexStringIfPresent(forKey: .librarySectionID)
+ librarySectionTitle = try values.decodeIfPresent(String.self, forKey: .librarySectionTitle)
+ parentTitle = try values.decodeIfPresent(String.self, forKey: .parentTitle)
+ grandparentTitle = try values.decodeIfPresent(String.self, forKey: .grandparentTitle)
+ year = values.decodePlexIntIfPresent(forKey: .year)
+ index = values.decodePlexIntIfPresent(forKey: .index)
+ parentIndex = values.decodePlexIntIfPresent(forKey: .parentIndex)
+ duration = values.decodePlexIntIfPresent(forKey: .duration)
+ summary = try values.decodeIfPresent(String.self, forKey: .summary)
+ thumb = try values.decodeIfPresent(String.self, forKey: .thumb)
+ composite = try values.decodeIfPresent(String.self, forKey: .composite)
+ parentThumb = try values.decodeIfPresent(String.self, forKey: .parentThumb)
+ grandparentThumb = try values.decodeIfPresent(String.self, forKey: .grandparentThumb)
+ art = try values.decodeIfPresent(String.self, forKey: .art)
+ images = try values.decodeIfPresent([PlexMediaImage].self, forKey: .images) ?? []
+ studio = try values.decodeIfPresent(String.self, forKey: .studio)
+ contentRating = try values.decodeIfPresent(String.self, forKey: .contentRating)
+ originallyAvailableAt = try values.decodeIfPresent(String.self, forKey: .originallyAvailableAt)
+ rating = values.decodePlexDoubleIfPresent(forKey: .rating)
+ ratingImage = try values.decodeIfPresent(String.self, forKey: .ratingImage)
+ audienceRating = values.decodePlexDoubleIfPresent(forKey: .audienceRating)
+ audienceRatingImage = try values.decodeIfPresent(String.self, forKey: .audienceRatingImage)
+ guids = try values.decodeIfPresent([PlexMediaGUID].self, forKey: .guids) ?? []
+ ratings = try values.decodeIfPresent([PlexMediaRating].self, forKey: .ratings) ?? []
+ userRating = values.decodePlexDoubleIfPresent(forKey: .userRating)
+ viewOffset = values.decodePlexIntIfPresent(forKey: .viewOffset)
+ viewCount = values.decodePlexIntIfPresent(forKey: .viewCount)
+ leafCount = values.decodePlexIntIfPresent(forKey: .leafCount)
+ viewedLeafCount = values.decodePlexIntIfPresent(forKey: .viewedLeafCount)
+ childCount = values.decodePlexIntIfPresent(forKey: .childCount)
+ skipChildren = values.decodePlexBoolIfPresent(forKey: .skipChildren)
+ skipParent = values.decodePlexBoolIfPresent(forKey: .skipParent)
+ primaryExtraKey = try values.decodeIfPresent(String.self, forKey: .primaryExtraKey)
+ playQueueItemID = values.decodePlexStringIfPresent(forKey: .playQueueItemID)
+ playlistItemID = values.decodePlexStringIfPresent(forKey: .playlistItemID)
+ playlistType = try values.decodeIfPresent(String.self, forKey: .playlistType)
+ smart = values.decodePlexBoolIfPresent(forKey: .smart)
+ readOnly = values.decodePlexBoolIfPresent(forKey: .readOnly)
+ media = try values.decodeIfPresent([PlexMediaVersion].self, forKey: .media) ?? []
+ genres = try values.decodeIfPresent([PlexTag].self, forKey: .genres) ?? []
+ directors = try values.decodeIfPresent([PlexTag].self, forKey: .directors) ?? []
+ writers = try values.decodeIfPresent([PlexTag].self, forKey: .writers) ?? []
+ producers = try values.decodeIfPresent([PlexTag].self, forKey: .producers) ?? []
+ countries = try values.decodeIfPresent([PlexTag].self, forKey: .countries) ?? []
+ roles = try values.decodeIfPresent([PlexTag].self, forKey: .roles) ?? []
+ chapters = try values.decodeIfPresent([PlexMediaChapter].self, forKey: .chapters) ?? []
+ markers = try values.decodeIfPresent([PlexMediaMarker].self, forKey: .markers) ?? []
+ }
+}
+
+public struct PlexMediaGUID: Decodable, Equatable, Hashable, Sendable {
+ public let id: String
+
+ public init(
+ id: String
+ ) {
+ self.id = id
+ }
+}
+
+public struct PlexMediaRating: Decodable, Equatable, Hashable, Sendable {
+ public let image: String?
+ public let type: String?
+ public let value: Double?
+
+ private enum CodingKeys: String, CodingKey {
+ case image
+ case type
+ case value
+ }
+
+ public init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ image = try values.decodeIfPresent(String.self, forKey: .image)
+ type = try values.decodeIfPresent(String.self, forKey: .type)
+ value = values.decodePlexDoubleIfPresent(forKey: .value)
+ }
+}
+
+public struct PlexMediaImage: Decodable, Equatable, Hashable, Sendable {
+ public let type: String
+ public let url: String
+ public let alt: String?
+
+ public init(
+ type: String,
+ url: String,
+ alt: String? = nil
+ ) {
+ self.type = type
+ self.url = url
+ self.alt = alt
+ }
+}
+
+public struct PlexMediaVersion: Decodable, Equatable, Hashable, Sendable {
+ public let id: Int?
+ public let container: String?
+ public let videoCodec: String?
+ public let audioCodec: String?
+ public let videoResolution: String?
+ public let width: Int?
+ public let height: Int?
+ public let bitrate: Int?
+ public let duration: Int?
+ public let selected: Bool?
+ public let parts: [PlexMediaPart]
+
+ private enum CodingKeys: String, CodingKey {
+ case id
+ case container
+ case videoCodec
+ case audioCodec
+ case videoResolution
+ case width
+ case height
+ case bitrate
+ case duration
+ case selected
+ case parts = "Part"
+ }
+
+ public init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ id = values.decodePlexIntIfPresent(forKey: .id)
+ container = try values.decodeIfPresent(String.self, forKey: .container)
+ videoCodec = try values.decodeIfPresent(String.self, forKey: .videoCodec)
+ audioCodec = try values.decodeIfPresent(String.self, forKey: .audioCodec)
+ videoResolution = try values.decodeIfPresent(String.self, forKey: .videoResolution)
+ width = values.decodePlexIntIfPresent(forKey: .width)
+ height = values.decodePlexIntIfPresent(forKey: .height)
+ bitrate = values.decodePlexIntIfPresent(forKey: .bitrate)
+ duration = values.decodePlexIntIfPresent(forKey: .duration)
+ selected = values.decodePlexBoolIfPresent(forKey: .selected)
+ parts = try values.decodeIfPresent([PlexMediaPart].self, forKey: .parts) ?? []
+ }
+}
+
+public struct PlexMediaPart: Decodable, Equatable, Hashable, Sendable {
+ public let id: Int?
+ public let key: String?
+ public let container: String?
+ public let duration: Int?
+ public let size: Int64?
+ public let decision: String?
+ public let selected: Bool?
+ public let streams: [PlexMediaStream]
+
+ private enum CodingKeys: String, CodingKey {
+ case id
+ case key
+ case container
+ case duration
+ case size
+ case decision
+ case selected
+ case streams = "Stream"
+ }
+
+ public init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ id = values.decodePlexIntIfPresent(forKey: .id)
+ key = try values.decodeIfPresent(String.self, forKey: .key)
+ container = try values.decodeIfPresent(String.self, forKey: .container)
+ duration = values.decodePlexIntIfPresent(forKey: .duration)
+ size = values.decodePlexInt64IfPresent(forKey: .size)
+ decision = try values.decodeIfPresent(String.self, forKey: .decision)
+ selected = values.decodePlexBoolIfPresent(forKey: .selected)
+ streams = try values.decodeIfPresent([PlexMediaStream].self, forKey: .streams) ?? []
+ }
+}
+
+public struct PlexMediaStream: Decodable, Equatable, Hashable, Sendable {
+ public let id: Int?
+ public let streamType: Int?
+ public let codec: String?
+ public let language: String?
+ public let languageCode: String?
+ public let displayTitle: String?
+ public let title: String?
+ public let channels: Int?
+ public let selected: Bool?
+ public let forced: Bool?
+ public let hearingImpaired: Bool?
+ public let visualImpaired: Bool?
+ public let canAutoSync: Bool?
+ public let offset: Int?
+ public let decision: String?
+ public let location: String?
+
+ private enum CodingKeys: String, CodingKey {
+ case id
+ case streamType
+ case codec
+ case language
+ case languageCode
+ case displayTitle
+ case title
+ case channels
+ case selected
+ case forced
+ case hearingImpaired
+ case visualImpaired
+ case canAutoSync
+ case offset
+ case decision
+ case location
+ }
+
+ public init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ id = values.decodePlexIntIfPresent(forKey: .id)
+ streamType = values.decodePlexIntIfPresent(forKey: .streamType)
+ codec = try values.decodeIfPresent(String.self, forKey: .codec)
+ language = try values.decodeIfPresent(String.self, forKey: .language)
+ languageCode = try values.decodeIfPresent(String.self, forKey: .languageCode)
+ displayTitle = try values.decodeIfPresent(String.self, forKey: .displayTitle)
+ title = try values.decodeIfPresent(String.self, forKey: .title)
+ channels = values.decodePlexIntIfPresent(forKey: .channels)
+ selected = values.decodePlexBoolIfPresent(forKey: .selected)
+ forced = values.decodePlexBoolIfPresent(forKey: .forced)
+ hearingImpaired = values.decodePlexBoolIfPresent(forKey: .hearingImpaired)
+ visualImpaired = values.decodePlexBoolIfPresent(forKey: .visualImpaired)
+ canAutoSync = values.decodePlexBoolIfPresent(forKey: .canAutoSync)
+ offset = values.decodePlexIntIfPresent(forKey: .offset)
+ decision = try values.decodeIfPresent(String.self, forKey: .decision)
+ location = try values.decodeIfPresent(String.self, forKey: .location)
+ }
+}
+
+public struct PlexTag: Decodable, Equatable, Hashable, Sendable {
+ public let id: Int?
+ public let tag: String
+ public let tagKey: String?
+ public let tagType: Int?
+ public let filter: String?
+ public let role: String?
+ public let thumb: String?
+ public let order: Int?
+
+ public init(
+ id: Int? = nil,
+ tag: String,
+ tagKey: String? = nil,
+ tagType: Int? = nil,
+ filter: String? = nil,
+ role: String? = nil,
+ thumb: String? = nil,
+ order: Int? = nil
+ ) {
+ self.id = id
+ self.tag = tag
+ self.tagKey = tagKey
+ self.tagType = tagType
+ self.filter = filter
+ self.role = role
+ self.thumb = thumb
+ self.order = order
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case id
+ case tag
+ case tagKey
+ case tagType
+ case filter
+ case role
+ case thumb
+ case order
+ }
+
+ public init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ id = values.decodePlexIntIfPresent(forKey: .id)
+ tag = try values.decodeIfPresent(String.self, forKey: .tag) ?? ""
+ tagKey = values.decodePlexStringIfPresent(forKey: .tagKey)
+ tagType = values.decodePlexIntIfPresent(forKey: .tagType)
+ filter = try values.decodeIfPresent(String.self, forKey: .filter)
+ role = try values.decodeIfPresent(String.self, forKey: .role)
+ thumb = try values.decodeIfPresent(String.self, forKey: .thumb)
+ order = values.decodePlexIntIfPresent(forKey: .order)
+ }
+}
+
+public struct PlexPeopleEnvelope: Decodable, Sendable {
+ public let mediaContainer: PlexPeopleContainer
+
+ enum CodingKeys: String, CodingKey {
+ case mediaContainer = "MediaContainer"
+ }
+
+ public init(
+ mediaContainer: PlexPeopleContainer
+ ) {
+ self.mediaContainer = mediaContainer
+ }
+}
+
+public struct PlexPeopleContainer: Decodable, Sendable {
+ public let people: [PlexTag]
+
+ enum CodingKeys: String, CodingKey {
+ case people = "Directory"
+ }
+
+ public init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ people = try values.decodeIfPresent([PlexTag].self, forKey: .people) ?? []
+ }
+}
+
+public struct PlexMediaPage: Equatable, Sendable {
+ public let items: [PlexMediaItem]
+ public let offset: Int
+ public let totalSize: Int?
+
+ public init(
+ items: [PlexMediaItem],
+ offset: Int,
+ totalSize: Int? = nil
+ ) {
+ self.items = items
+ self.offset = offset
+ self.totalSize = totalSize
+ }
+}
+
+public struct PlexMediaEnvelope: Decodable, Sendable {
+ public let mediaContainer: PlexMediaContainer
+
+ enum CodingKeys: String, CodingKey {
+ case mediaContainer = "MediaContainer"
+ }
+
+ public init(
+ mediaContainer: PlexMediaContainer
+ ) {
+ self.mediaContainer = mediaContainer
+ }
+}
+
+public struct PlexMediaContainer: Decodable, Sendable {
+ public let size: Int?
+ public let totalSize: Int?
+ public let offset: Int?
+ public let metadata: [PlexMediaItem]
+
+ enum CodingKeys: String, CodingKey {
+ case size
+ case totalSize
+ case offset
+ case metadata = "Metadata"
+ }
+
+ public init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ size = values.decodePlexIntIfPresent(forKey: .size)
+ totalSize = values.decodePlexIntIfPresent(forKey: .totalSize)
+ offset = values.decodePlexIntIfPresent(forKey: .offset)
+ metadata = try values.decodeIfPresent([PlexMediaItem].self, forKey: .metadata) ?? []
+ }
+}
diff --git a/Packages/PlexData/Sources/PlexModels/PlexMediaChapter.swift b/Packages/PlexData/Sources/PlexModels/PlexMediaChapter.swift
new file mode 100644
index 0000000..ddbb0f5
--- /dev/null
+++ b/Packages/PlexData/Sources/PlexModels/PlexMediaChapter.swift
@@ -0,0 +1,29 @@
+import Foundation
+
+public struct PlexMediaChapter: Decodable, Equatable, Hashable, Sendable {
+ public let id: String?
+ public let index: Int?
+ public let startTimeOffset: Int?
+ public let endTimeOffset: Int?
+ public let title: String?
+ public let thumb: String?
+
+ private enum CodingKeys: String, CodingKey {
+ case id
+ case index
+ case startTimeOffset
+ case endTimeOffset
+ case title
+ case thumb
+ }
+
+ public init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ id = values.decodePlexStringIfPresent(forKey: .id)
+ index = values.decodePlexIntIfPresent(forKey: .index)
+ startTimeOffset = values.decodePlexIntIfPresent(forKey: .startTimeOffset)
+ endTimeOffset = values.decodePlexIntIfPresent(forKey: .endTimeOffset)
+ title = try values.decodeIfPresent(String.self, forKey: .title)
+ thumb = try values.decodeIfPresent(String.self, forKey: .thumb)
+ }
+}
diff --git a/Packages/PlexData/Sources/PlexModels/PlexMediaMarker.swift b/Packages/PlexData/Sources/PlexModels/PlexMediaMarker.swift
new file mode 100644
index 0000000..6ebc9f8
--- /dev/null
+++ b/Packages/PlexData/Sources/PlexModels/PlexMediaMarker.swift
@@ -0,0 +1,26 @@
+import Foundation
+
+public struct PlexMediaMarker: Decodable, Equatable, Hashable, Sendable {
+ public let id: String?
+ public let type: String
+ public let startTimeOffset: Int?
+ public let endTimeOffset: Int?
+ public let isFinal: Bool?
+
+ private enum CodingKeys: String, CodingKey {
+ case id
+ case type
+ case startTimeOffset
+ case endTimeOffset
+ case isFinal = "final"
+ }
+
+ public init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ id = values.decodePlexStringIfPresent(forKey: .id)
+ type = try values.decode(String.self, forKey: .type)
+ startTimeOffset = values.decodePlexIntIfPresent(forKey: .startTimeOffset)
+ endTimeOffset = values.decodePlexIntIfPresent(forKey: .endTimeOffset)
+ isFinal = values.decodePlexBoolIfPresent(forKey: .isFinal)
+ }
+}
diff --git a/Packages/PlexData/Sources/PlexModels/PlexResolvedConnection.swift b/Packages/PlexData/Sources/PlexModels/PlexResolvedConnection.swift
new file mode 100644
index 0000000..fba9c3f
--- /dev/null
+++ b/Packages/PlexData/Sources/PlexModels/PlexResolvedConnection.swift
@@ -0,0 +1,37 @@
+import Foundation
+
+public enum PlexConnectionKind: String, Codable, Sendable {
+ case local
+ case remote
+ case relay
+
+ public var displayName: String {
+ switch self {
+ case .local:
+ return "Local"
+ case .remote:
+ return "Remote"
+ case .relay:
+ return "Relay"
+ }
+ }
+}
+
+public struct PlexResolvedConnection: Equatable, Sendable {
+ public let serverID: String
+ public let url: URL
+ public let kind: PlexConnectionKind
+ public let validatedAt: Date
+
+ public init(
+ serverID: String,
+ url: URL,
+ kind: PlexConnectionKind,
+ validatedAt: Date
+ ) {
+ self.serverID = serverID
+ self.url = url
+ self.kind = kind
+ self.validatedAt = validatedAt
+ }
+}
diff --git a/Packages/PlexData/Sources/PlexModels/PlexServerResource.swift b/Packages/PlexData/Sources/PlexModels/PlexServerResource.swift
new file mode 100644
index 0000000..6f30ffa
--- /dev/null
+++ b/Packages/PlexData/Sources/PlexModels/PlexServerResource.swift
@@ -0,0 +1,106 @@
+import Foundation
+
+/// Keeps transport causes without retaining authenticated URLs or request headers.
+public struct PlexServerConnectionFailure: LocalizedError, Sendable {
+ public let serverName: String
+ public let failureCodes: [URLError.Code]
+
+ public var errorDescription: String? {
+ var uniqueCodes: [URLError.Code] = []
+ for code in failureCodes where !uniqueCodes.contains(code) {
+ uniqueCodes.append(code)
+ }
+ let reasons = uniqueCodes.map { URLError($0).localizedDescription }
+ return (["PlexBar could not reach any advertised connection for \(serverName)."] + reasons)
+ .joined(separator: "\n\n")
+ }
+
+ public init(
+ serverName: String,
+ failureCodes: [URLError.Code]
+ ) {
+ self.serverName = serverName
+ self.failureCodes = failureCodes
+ }
+}
+
+public struct PlexServerResource: Identifiable, Equatable, Hashable, Sendable {
+ public let id: String
+ public let name: String
+ public let productVersion: String?
+ public let accessToken: String
+ public let connections: [PlexServerConnection]
+
+ public var displayProductVersion: String? {
+ guard let productVersion = productVersion?.nilIfBlank else {
+ return nil
+ }
+
+ return productVersion.split(separator: "-", maxSplits: 1).first.map(String.init)
+ }
+
+ public var preferredConnection: PlexServerConnection? {
+ connections.min { lhs, rhs in
+ if lhs.priorityTier != rhs.priorityTier {
+ return lhs.priorityTier < rhs.priorityTier
+ }
+
+ let lhsIsHTTPS = lhs.uri.scheme?.localizedCaseInsensitiveCompare("https") == .orderedSame
+ let rhsIsHTTPS = rhs.uri.scheme?.localizedCaseInsensitiveCompare("https") == .orderedSame
+ if lhsIsHTTPS != rhsIsHTTPS {
+ return lhsIsHTTPS
+ }
+
+ return lhs.uri.absoluteString.localizedCaseInsensitiveCompare(rhs.uri.absoluteString) == .orderedAscending
+ }
+ }
+
+ public init(
+ id: String,
+ name: String,
+ productVersion: String? = nil,
+ accessToken: String,
+ connections: [PlexServerConnection]
+ ) {
+ self.id = id
+ self.name = name
+ self.productVersion = productVersion
+ self.accessToken = accessToken
+ self.connections = connections
+ }
+}
+
+public struct PlexServerConnection: Equatable, Hashable, Sendable {
+ public let uri: URL
+ public let local: Bool
+ public let relay: Bool
+
+ public var kind: PlexConnectionKind {
+ if relay {
+ return .relay
+ }
+
+ return local ? .local : .remote
+ }
+
+ public var priorityTier: Int {
+ switch kind {
+ case .local:
+ return 0
+ case .remote:
+ return 1
+ case .relay:
+ return 2
+ }
+ }
+
+ public init(
+ uri: URL,
+ local: Bool,
+ relay: Bool
+ ) {
+ self.uri = uri
+ self.local = local
+ self.relay = relay
+ }
+}
diff --git a/Sources/PlexBar/Models/PlexSession.swift b/Packages/PlexData/Sources/PlexModels/PlexSession.swift
similarity index 67%
rename from Sources/PlexBar/Models/PlexSession.swift
rename to Packages/PlexData/Sources/PlexModels/PlexSession.swift
index 18e30f1..89a2681 100644
--- a/Sources/PlexBar/Models/PlexSession.swift
+++ b/Packages/PlexData/Sources/PlexModels/PlexSession.swift
@@ -1,6 +1,6 @@
import Foundation
-enum PlexSessionContentKind: String, Equatable {
+public enum PlexSessionContentKind: String, Equatable, Sendable {
case movie
case tv
case liveTV
@@ -9,7 +9,7 @@ enum PlexSessionContentKind: String, Equatable {
case clip
case other
- init(type: String?, live: Bool) {
+ public init(type: String?, live: Bool) {
if live {
self = .liveTV
return
@@ -31,7 +31,7 @@ enum PlexSessionContentKind: String, Equatable {
}
}
- var displayName: String {
+ public var displayName: String {
switch self {
case .movie:
"Movie"
@@ -50,11 +50,11 @@ enum PlexSessionContentKind: String, Equatable {
}
}
- var contentMetaSymbolName: String {
+ public var contentMetaSymbolName: String {
symbolName
}
- var contentMetaLabel: String {
+ public var contentMetaLabel: String {
switch self {
case .movie:
"Movie"
@@ -73,7 +73,7 @@ enum PlexSessionContentKind: String, Equatable {
}
}
- var symbolName: String {
+ public var symbolName: String {
icon.symbolName
}
@@ -96,7 +96,7 @@ enum PlexSessionContentKind: String, Equatable {
}
}
- var streamArtworkLayout: PlexStreamArtworkLayout {
+ public var streamArtworkLayout: PlexStreamArtworkLayout {
switch self {
case .track:
.squareCover
@@ -106,11 +106,11 @@ enum PlexSessionContentKind: String, Equatable {
}
}
-enum PlexStreamArtworkLayout: Equatable {
+public enum PlexStreamArtworkLayout: Equatable, Sendable {
case poster
case squareCover
- var aspectRatio: Double {
+ public var aspectRatio: Double {
switch self {
case .poster:
2.0 / 3.0
@@ -120,30 +120,30 @@ enum PlexStreamArtworkLayout: Equatable {
}
}
-struct PlexSession: Decodable, Identifiable {
- let sessionKey: String?
- let ratingKey: String?
- let key: String?
- let type: String?
- let subtype: String?
- let live: Bool?
- let title: String
- let grandparentTitle: String?
- let parentTitle: String?
- let parentIndex: Int?
- let index: Int?
- let thumb: String?
- let parentThumb: String?
- let grandparentThumb: String?
- let art: String?
- let duration: Int?
- let viewOffset: Int?
- let year: Int?
- let user: PlexUser?
- let player: PlexPlayer
- let session: PlexPlaybackSession?
- let transcodeSession: PlexTranscodeSession?
- let media: [PlexMedia]?
+public struct PlexSession: Decodable, Identifiable, Sendable {
+ public let sessionKey: String?
+ public let ratingKey: String?
+ public let key: String?
+ public let type: String?
+ public let subtype: String?
+ public let live: Bool?
+ public let title: String
+ public let grandparentTitle: String?
+ public let parentTitle: String?
+ public let parentIndex: Int?
+ public let index: Int?
+ public let thumb: String?
+ public let parentThumb: String?
+ public let grandparentThumb: String?
+ public let art: String?
+ public let duration: Int?
+ public let viewOffset: Int?
+ public let year: Int?
+ public let user: PlexUser?
+ public let player: PlexPlayer
+ public let session: PlexPlaybackSession?
+ public let transcodeSession: PlexTranscodeSession?
+ public let media: [PlexMedia]?
enum CodingKeys: String, CodingKey {
case sessionKey
@@ -171,7 +171,7 @@ struct PlexSession: Decodable, Identifiable {
case media = "Media"
}
- init(
+ public init(
sessionKey: String? = nil,
ratingKey: String?,
key: String?,
@@ -221,7 +221,7 @@ struct PlexSession: Decodable, Identifiable {
self.media = media
}
- init(from decoder: Decoder) throws {
+ public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
sessionKey = try container.decodeIfPresent(String.self, forKey: .sessionKey)
@@ -249,7 +249,7 @@ struct PlexSession: Decodable, Identifiable {
media = try container.decodeIfPresent([PlexMedia].self, forKey: .media)
}
- var id: String {
+ public var id: String {
guard let canonicalSessionKey else {
preconditionFailure("PlexSession requires a canonical session key for active stream identity.")
}
@@ -257,37 +257,37 @@ struct PlexSession: Decodable, Identifiable {
return canonicalSessionKey
}
- var canonicalSessionKey: String? {
+ public var canonicalSessionKey: String? {
sessionKey?.nilIfBlank ?? session?.id?.nilIfBlank
}
- var transcodeSessionKey: String? {
+ public var transcodeSessionKey: String? {
transcodeSession?.key?.nilIfBlank
}
- var serverSessionID: String? {
+ public var serverSessionID: String? {
session?.id?.nilIfBlank
}
- var posterPath: String? {
+ public var posterPath: String? {
preferredPosterCandidates
.compactMap { $0?.nilIfBlank }
.first
}
- var contentKind: PlexSessionContentKind {
+ public var contentKind: PlexSessionContentKind {
PlexSessionContentKind(type: type, live: isLive)
}
- var streamArtworkLayout: PlexStreamArtworkLayout {
+ public var streamArtworkLayout: PlexStreamArtworkLayout {
contentKind.streamArtworkLayout
}
- var isLive: Bool {
+ public var isLive: Bool {
live == true
}
- var headline: String {
+ public var headline: String {
switch contentKind {
case .tv, .liveTV:
return grandparentTitle ?? title
@@ -298,12 +298,10 @@ struct PlexSession: Decodable, Identifiable {
}
}
- var detailLine: String {
+ public var detailLine: String {
switch contentKind {
case .tv, .liveTV:
- let episodeLabel = episodeCode
- let pieces = [episodeLabel, title].compactMap { $0 }
- return pieces.joined(separator: " • ")
+ return PlexEpisodeText.subtitle(season: parentIndex, episode: index, title: title)
case .track:
let pieces = [parentTitle, title].compactMap { $0?.nilIfBlank }
return pieces.joined(separator: " • ")
@@ -314,7 +312,7 @@ struct PlexSession: Decodable, Identifiable {
}
}
- var contentSubtitle: String? {
+ public var contentSubtitle: String? {
switch contentKind {
case .tv, .liveTV:
return title.nilIfBlank
@@ -325,7 +323,7 @@ struct PlexSession: Decodable, Identifiable {
}
}
- var contentMetaLine: String? {
+ public var contentMetaLine: String? {
switch contentKind {
case .tv, .liveTV:
return seasonEpisodeLine
@@ -338,19 +336,19 @@ struct PlexSession: Decodable, Identifiable {
}
}
- var viewerLine: String {
+ public var viewerLine: String {
"\(userDisplayName) on \(playerDisplayName)"
}
- var playbackLine: String {
+ public var playbackLine: String {
[playbackStatusDisplayName, decisionDisplayName, locationDisplayName].compactMap { $0?.nilIfBlank }.joined(separator: " • ")
}
- var isPaused: Bool {
+ public var isPaused: Bool {
player.state?.nilIfBlank?.lowercased() == "paused"
}
- var geoLookupIPAddress: String? {
+ public var geoLookupIPAddress: String? {
guard player.relayed != true else {
return nil
}
@@ -358,7 +356,7 @@ struct PlexSession: Decodable, Identifiable {
return player.remotePublicAddress?.nilIfBlank
}
- func applying(playNotification: PlexPlaySessionStateNotification) -> PlexSession {
+ public func applying(playNotification: PlexPlaySessionStateNotification) -> PlexSession {
PlexSession(
sessionKey: playNotification.sessionKey ?? sessionKey,
ratingKey: playNotification.hasRatingKey ? playNotification.ratingKey : ratingKey,
@@ -395,10 +393,13 @@ struct PlexSession: Decodable, Identifiable {
return nil
}
+ if transcodeSession?.key == transcodeSessionKey {
+ return transcodeSession
+ }
return PlexTranscodeSession(key: transcodeSessionKey)
}
- var progress: Double? {
+ public var progress: Double? {
guard let duration, duration > 0, let viewOffset else {
return nil
}
@@ -407,7 +408,7 @@ struct PlexSession: Decodable, Identifiable {
return min(max(rawProgress, 0), 1)
}
- var audioStreamID: Int? {
+ public var audioStreamID: Int? {
let audioStreams = media?
.flatMap { $0.part ?? [] }
.flatMap { $0.stream ?? [] }
@@ -416,7 +417,7 @@ struct PlexSession: Decodable, Identifiable {
return audioStreams.first(where: { $0.selected == true })?.id ?? audioStreams.first?.id
}
- func playbackTimingSummary(
+ public func playbackTimingSummary(
referenceDate: Date,
locale: Locale = .autoupdatingCurrent,
timeZone: TimeZone = .autoupdatingCurrent
@@ -442,11 +443,11 @@ struct PlexSession: Decodable, Identifiable {
return "\(remainingTimeText) (\(endTime))"
}
- var userDisplayName: String {
+ public var userDisplayName: String {
user?.title?.nilIfBlank ?? "Unknown User"
}
- var playerDisplayName: String {
+ public var playerDisplayName: String {
player.title?.nilIfBlank ?? player.product?.nilIfBlank ?? "Unknown Player"
}
@@ -482,19 +483,8 @@ struct PlexSession: Decodable, Identifiable {
return max(duration - min(max(viewOffset, 0), duration), 0)
}
- private var episodeCode: String? {
- let season = parentIndex.map { "S\($0)" }
- let episode = index.map { String(format: "E%02d", $0) }
-
- let code = [season, episode].compactMap { $0 }.joined()
- return code.isEmpty ? nil : code
- }
-
private var seasonEpisodeLine: String? {
- let season = parentIndex.map { "S\($0)" }
- let episode = index.map { "E\($0)" }
- let pieces = [season, episode].compactMap { $0 }
- return pieces.isEmpty ? nil : pieces.joined(separator: " • ")
+ PlexEpisodeText.numbers(season: parentIndex, episode: index)
}
private var preferredPosterCandidates: [String?] {
@@ -527,25 +517,35 @@ struct PlexSession: Decodable, Identifiable {
}
}
-struct PlexUser: Decodable {
- let id: String?
- let thumb: String?
- let title: String?
+public struct PlexUser: Decodable, Sendable {
+ public let id: String?
+ public let thumb: String?
+ public let title: String?
+
+ public init(
+ id: String? = nil,
+ thumb: String? = nil,
+ title: String? = nil
+ ) {
+ self.id = id
+ self.thumb = thumb
+ self.title = title
+ }
}
-struct PlexPlayer: Decodable {
- let address: String?
- let machineIdentifier: String?
- let platform: String?
- let product: String?
- let remotePublicAddress: String?
- let state: String?
- let title: String?
- let local: Bool?
- let relayed: Bool?
- let secure: Bool?
-
- init(
+public struct PlexPlayer: Decodable, Sendable {
+ public let address: String?
+ public let machineIdentifier: String?
+ public let platform: String?
+ public let product: String?
+ public let remotePublicAddress: String?
+ public let state: String?
+ public let title: String?
+ public let local: Bool?
+ public let relayed: Bool?
+ public let secure: Bool?
+
+ public init(
address: String?,
machineIdentifier: String?,
platform: String?,
@@ -569,7 +569,7 @@ struct PlexPlayer: Decodable {
self.secure = secure
}
- func updating(state: String?) -> PlexPlayer {
+ public func updating(state: String?) -> PlexPlayer {
PlexPlayer(
address: address,
machineIdentifier: machineIdentifier,
@@ -585,58 +585,118 @@ struct PlexPlayer: Decodable {
}
}
-struct PlexPlaybackSession: Decodable {
- let id: String?
- let bandwidth: Int?
- let location: String?
+public struct PlexPlaybackSession: Decodable, Sendable {
+ public let id: String?
+ public let bandwidth: Int?
+ public let location: String?
+
+ public init(
+ id: String? = nil,
+ bandwidth: Int? = nil,
+ location: String? = nil
+ ) {
+ self.id = id
+ self.bandwidth = bandwidth
+ self.location = location
+ }
}
-struct PlexTranscodeSession: Decodable {
- let key: String?
+public struct PlexTranscodeSession: Decodable, Sendable {
+ public let key: String?
+ public let videoDecision: String?
+ public let audioDecision: String?
+ public let transcodeHwDecoding: String?
+ public let transcodeHwEncoding: String?
+ public let sourceVideoCodec: String?
+ public let sourceAudioCodec: String?
+ public let videoCodec: String?
+ public let audioCodec: String?
+
+ public init(key: String?, videoDecision: String? = nil, audioDecision: String? = nil,
+ transcodeHwDecoding: String? = nil, transcodeHwEncoding: String? = nil,
+ sourceVideoCodec: String? = nil, sourceAudioCodec: String? = nil,
+ videoCodec: String? = nil, audioCodec: String? = nil) {
+ self.key = key
+ self.videoDecision = videoDecision
+ self.audioDecision = audioDecision
+ self.transcodeHwDecoding = transcodeHwDecoding
+ self.transcodeHwEncoding = transcodeHwEncoding
+ self.sourceVideoCodec = sourceVideoCodec
+ self.sourceAudioCodec = sourceAudioCodec
+ self.videoCodec = videoCodec
+ self.audioCodec = audioCodec
+ }
}
-struct PlexMedia: Decodable {
- let part: [PlexPart]?
+public struct PlexMedia: Decodable, Sendable {
+ public let part: [PlexPart]?
+ public let selected: Bool?
- init(part: [PlexPart]?) {
+ public init(part: [PlexPart]?, selected: Bool? = nil) {
self.part = part
+ self.selected = selected
}
enum CodingKeys: String, CodingKey {
case part = "Part"
+ case selected
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ part = try container.decodeIfPresent([PlexPart].self, forKey: .part)
+ selected = try container.decodeFlexibleBoolIfPresent(forKey: .selected)
}
}
-struct PlexPart: Decodable {
- let decision: String?
- let stream: [PlexStream]?
+public struct PlexPart: Decodable, Sendable {
+ public let decision: String?
+ public let stream: [PlexStream]?
+ public let selected: Bool?
- init(decision: String?, stream: [PlexStream]? = nil) {
+ public init(decision: String?, stream: [PlexStream]? = nil, selected: Bool? = nil) {
self.decision = decision
self.stream = stream
+ self.selected = selected
}
enum CodingKeys: String, CodingKey {
case decision
case stream = "Stream"
+ case selected
}
-}
-struct PlexStream: Decodable {
- let id: Int?
- let streamType: Int?
- let codec: String?
- let selected: Bool?
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ decision = try container.decodeIfPresent(String.self, forKey: .decision)
+ stream = try container.decodeIfPresent([PlexStream].self, forKey: .stream)
+ selected = try container.decodeFlexibleBoolIfPresent(forKey: .selected)
+ }
+}
- var isAudio: Bool {
+public struct PlexStream: Decodable, Sendable {
+ public let id: Int?
+ public let streamType: Int?
+ public let codec: String?
+ public let selected: Bool?
+ public let decision: String?
+ public let displayTitle: String?
+ public let language: String?
+ public let bitrate: Int?
+
+ public var isAudio: Bool {
streamType == 2
}
- init(id: Int?, streamType: Int?, codec: String? = nil, selected: Bool? = nil) {
+ public init(id: Int?, streamType: Int?, codec: String? = nil, selected: Bool? = nil, decision: String? = nil, displayTitle: String? = nil, language: String? = nil, bitrate: Int? = nil) {
self.id = id
self.streamType = streamType
self.codec = codec
self.selected = selected
+ self.decision = decision
+ self.displayTitle = displayTitle
+ self.language = language
+ self.bitrate = bitrate
}
enum CodingKeys: String, CodingKey {
@@ -644,44 +704,64 @@ struct PlexStream: Decodable {
case streamType
case codec
case selected
+ case decision
+ case displayTitle
+ case language
+ case bitrate
}
- init(from decoder: Decoder) throws {
+ public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeFlexibleIntIfPresent(forKey: .id)
streamType = try container.decodeFlexibleIntIfPresent(forKey: .streamType)
codec = try container.decodeIfPresent(String.self, forKey: .codec)
selected = try container.decodeFlexibleBoolIfPresent(forKey: .selected)
+ decision = try container.decodeIfPresent(String.self, forKey: .decision)
+ displayTitle = try container.decodeIfPresent(String.self, forKey: .displayTitle)
+ language = try container.decodeIfPresent(String.self, forKey: .language)
+ bitrate = try container.decodeFlexibleIntIfPresent(forKey: .bitrate)
}
}
-struct PlexStreamLevelsEnvelope: Decodable {
- let mediaContainer: PlexStreamLevelsContainer
+public struct PlexStreamLevelsEnvelope: Decodable, Sendable {
+ public let mediaContainer: PlexStreamLevelsContainer
enum CodingKeys: String, CodingKey {
case mediaContainer = "MediaContainer"
}
+
+ public init(
+ mediaContainer: PlexStreamLevelsContainer
+ ) {
+ self.mediaContainer = mediaContainer
+ }
}
-struct PlexStreamLevelsContainer: Decodable {
- let levels: [PlexStreamLevel]
+public struct PlexStreamLevelsContainer: Decodable, Sendable {
+ public let levels: [PlexStreamLevel]
enum CodingKeys: String, CodingKey {
case levels = "Level"
}
- init(from decoder: Decoder) throws {
+ public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
levels = try container.decodeIfPresent([PlexStreamLevel].self, forKey: .levels) ?? []
}
}
-struct PlexStreamLevel: Decodable {
- let value: Double?
+public struct PlexStreamLevel: Decodable, Sendable {
+ public let value: Double?
enum CodingKeys: String, CodingKey {
case value = "v"
}
+
+ public init(
+ value: Double? = nil
+ ) {
+ self.value = value
+ }
}
private extension KeyedDecodingContainer {
diff --git a/Sources/PlexBar/Models/PlexSessionNotification.swift b/Packages/PlexData/Sources/PlexModels/PlexSessionNotification.swift
similarity index 64%
rename from Sources/PlexBar/Models/PlexSessionNotification.swift
rename to Packages/PlexData/Sources/PlexModels/PlexSessionNotification.swift
index 2de2e86..c3f23b4 100644
--- a/Sources/PlexBar/Models/PlexSessionNotification.swift
+++ b/Packages/PlexData/Sources/PlexModels/PlexSessionNotification.swift
@@ -1,23 +1,29 @@
import Foundation
-enum PlexSessionEvent: Equatable, Sendable {
+public enum PlexSessionEvent: Equatable, Sendable {
case connected
case playing(PlexPlaySessionStateNotification)
case transcodeSessionUpdate(PlexTranscodeSessionUpdate)
}
-struct PlexSessionNotificationEnvelope: Decodable {
- let notificationContainer: PlexSessionNotificationContainer
+public struct PlexSessionNotificationEnvelope: Decodable, Sendable {
+ public let notificationContainer: PlexSessionNotificationContainer
enum CodingKeys: String, CodingKey {
case notificationContainer = "NotificationContainer"
}
+
+ public init(
+ notificationContainer: PlexSessionNotificationContainer
+ ) {
+ self.notificationContainer = notificationContainer
+ }
}
-struct PlexSessionNotificationContainer: Decodable {
- let type: String?
- let playbackStateNotifications: [PlexPlaySessionStateNotification]
- let transcodeSessionUpdateNotifications: [PlexTranscodeSessionUpdate]
+public struct PlexSessionNotificationContainer: Decodable, Sendable {
+ public let type: String?
+ public let playbackStateNotifications: [PlexPlaySessionStateNotification]
+ public let transcodeSessionUpdateNotifications: [PlexTranscodeSessionUpdate]
enum CodingKeys: String, CodingKey {
case type
@@ -25,14 +31,14 @@ struct PlexSessionNotificationContainer: Decodable {
case transcodeSessionUpdateNotifications = "TranscodeSession"
}
- init(from decoder: Decoder) throws {
+ public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
type = try container.decodeIfPresent(String.self, forKey: .type)
playbackStateNotifications = try container.decodeIfPresent([PlexPlaySessionStateNotification].self, forKey: .playbackStateNotifications) ?? []
transcodeSessionUpdateNotifications = try container.decodeIfPresent([PlexTranscodeSessionUpdate].self, forKey: .transcodeSessionUpdateNotifications) ?? []
}
- var sessionEvents: [PlexSessionEvent] {
+ public var sessionEvents: [PlexSessionEvent] {
let normalizedType = type?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
switch normalizedType {
@@ -47,17 +53,17 @@ struct PlexSessionNotificationContainer: Decodable {
}
}
-struct PlexPlaySessionStateNotification: Decodable, Equatable, Sendable {
- let sessionKey: String?
- let state: String?
- let viewOffset: Int?
- let ratingKey: String?
- let key: String?
- let transcodeSessionKey: String?
- let hasViewOffset: Bool
- let hasRatingKey: Bool
- let hasKey: Bool
- let hasTranscodeSession: Bool
+public struct PlexPlaySessionStateNotification: Decodable, Equatable, Sendable {
+ public let sessionKey: String?
+ public let state: String?
+ public let viewOffset: Int?
+ public let ratingKey: String?
+ public let key: String?
+ public let transcodeSessionKey: String?
+ public let hasViewOffset: Bool
+ public let hasRatingKey: Bool
+ public let hasKey: Bool
+ public let hasTranscodeSession: Bool
enum CodingKeys: String, CodingKey {
case sessionKey
@@ -68,7 +74,7 @@ struct PlexPlaySessionStateNotification: Decodable, Equatable, Sendable {
case transcodeSession
}
- init(
+ public init(
sessionKey: String?,
state: String?,
viewOffset: Int?,
@@ -92,7 +98,7 @@ struct PlexPlaySessionStateNotification: Decodable, Equatable, Sendable {
self.hasTranscodeSession = hasTranscodeSession
}
- init(from decoder: Decoder) throws {
+ public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
sessionKey = try container.decodeIfPresent(String.self, forKey: .sessionKey)
state = try container.decodeIfPresent(String.self, forKey: .state)
@@ -103,14 +109,20 @@ struct PlexPlaySessionStateNotification: Decodable, Equatable, Sendable {
hasKey = container.contains(.key)
key = try container.decodeIfPresent(String.self, forKey: .key)
hasTranscodeSession = container.contains(.transcodeSession)
- transcodeSessionKey = try container.decodeIfPresent(PlexTranscodeSessionReference.self, forKey: .transcodeSession)?.key
+ let transcodeID = try container.decodeIfPresent(String.self, forKey: .transcodeSession)
+ // Notifications carry the bare ID; HTTP session snapshots carry this path.
+ transcodeSessionKey = transcodeID
+ .flatMap { $0.nilIfBlank }
+ .map { "/transcode/sessions/\($0)" }
}
}
-struct PlexTranscodeSessionUpdate: Decodable, Equatable, Sendable {
- let key: String?
-}
+public struct PlexTranscodeSessionUpdate: Decodable, Equatable, Sendable {
+ public let key: String?
-private struct PlexTranscodeSessionReference: Decodable {
- let key: String?
+ public init(
+ key: String? = nil
+ ) {
+ self.key = key
+ }
}
diff --git a/Packages/PlexData/Sources/PlexModels/String+Normalization.swift b/Packages/PlexData/Sources/PlexModels/String+Normalization.swift
new file mode 100644
index 0000000..f6c1b2d
--- /dev/null
+++ b/Packages/PlexData/Sources/PlexModels/String+Normalization.swift
@@ -0,0 +1,8 @@
+import Foundation
+
+extension String {
+ public var nilIfBlank: String? {
+ let trimmedValue = trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmedValue.isEmpty ? nil : trimmedValue
+ }
+}
diff --git a/Packages/PlexData/Tests/PlexMockDataTests/PlexMockMediaCatalogTests.swift b/Packages/PlexData/Tests/PlexMockDataTests/PlexMockMediaCatalogTests.swift
new file mode 100644
index 0000000..7692a12
--- /dev/null
+++ b/Packages/PlexData/Tests/PlexMockDataTests/PlexMockMediaCatalogTests.swift
@@ -0,0 +1,58 @@
+import Foundation
+import PlexMockData
+import Testing
+
+@Suite struct PlexMockMediaCatalogTests {
+ @Test func preservesPMSFieldsAndAppliesRelativeDates() throws {
+ let data = try catalogData([
+ entry(id: "movie", metadata: ["customField": ["value": "preserved"]]),
+ ])
+ let catalog = try PlexMockMediaCatalog(data: data)
+ let record = try #require(catalog.record(for: "movie"))
+ let object = record.object(referenceDate: Date(timeIntervalSince1970: 1_000))
+
+ #expect(record.item.title == "Example")
+ #expect(record.sources.map(\.absoluteString) == ["https://example.com/source"])
+ #expect(object["customField"] as? [String: String] == ["value": "preserved"])
+ #expect(object["addedAt"] as? Int == 900)
+ }
+
+ @Test func rejectsDuplicateIDsAndPlaybackParts() throws {
+ let duplicate = try catalogData([entry(id: "movie"), entry(id: "movie")])
+ #expect(throws: PlexMockMediaCatalog.CatalogError.self) {
+ try PlexMockMediaCatalog(data: duplicate)
+ }
+
+ let playable = try catalogData([
+ entry(id: "movie", metadata: ["Media": [["Part": [["key": "/playback"]]]]]),
+ ])
+ #expect(throws: PlexMockMediaCatalog.CatalogError.self) {
+ try PlexMockMediaCatalog(data: playable)
+ }
+ }
+
+ @Test func rejectsCyclesBeforeTraversingChildCounts() throws {
+ let cyclic = try catalogData([
+ entry(id: "a", metadata: ["type": "show", "parentRatingKey": "b", "childCount": 1]),
+ entry(id: "b", metadata: ["type": "show", "parentRatingKey": "a", "childCount": 1]),
+ ])
+ #expect(throws: PlexMockMediaCatalog.CatalogError.self) {
+ try PlexMockMediaCatalog(data: cyclic)
+ }
+ }
+
+ private func entry(id: String, metadata: [String: Any] = [:]) -> [String: Any] {
+ [
+ "metadata": ["ratingKey": id, "key": "/library/metadata/\(id)", "type": "movie", "title": "Example"]
+ .merging(metadata) { _, replacement in replacement },
+ "sources": ["https://example.com/source"],
+ "addedAtSecondsAgo": 100,
+ "relatedIDs": [String](),
+ "extraIDs": [String](),
+ ]
+ }
+
+ private func catalogData(_ entries: [[String: Any]]) throws -> Data {
+ try JSONSerialization.data(withJSONObject: entries)
+ }
+}
diff --git a/Packages/PlexData/Tests/PlexMockDataTests/PlexMockUserProfileTests.swift b/Packages/PlexData/Tests/PlexMockDataTests/PlexMockUserProfileTests.swift
new file mode 100644
index 0000000..182786b
--- /dev/null
+++ b/Packages/PlexData/Tests/PlexMockDataTests/PlexMockUserProfileTests.swift
@@ -0,0 +1,119 @@
+import Foundation
+import PlexMockData
+import Testing
+
+@Suite struct PlexMockUserProfileTests {
+ private var user: [String: Any] {
+ ["id": 11, "friendlyName": "Elliot", "username": "elliot", "email": "elliot@example.com",
+ "avatar": "/avatar.png", "devices": [device]]
+ }
+
+ private var device: [String: Any] {
+ ["id": 1, "title": "Iceweasel", "machineIdentifier": "elliot-browser",
+ "platform": "Linux", "product": "Plex Web",
+ "connection": ["remotePublicAddress": "203.0.113.24", "resolvedLocation": "Brooklyn, NY",
+ "local": false, "relayed": false, "secure": true]]
+ }
+
+ private func payload(users: [[String: Any]]? = nil, sessionDeviceID: Int = 1,
+ historyDeviceID: Int? = 1, authenticatedUserID: Int = 11) throws -> PlexMockServerPayload {
+ var event: [String: Any] = ["historyKey": "history-1", "userID": 11, "mediaType": "movie",
+ "mediaID": "movie-1", "viewedAtSecondsAgo": 100]
+ event["deviceID"] = historyDeviceID
+ let object: [String: Any] = [
+ "authenticatedUserID": authenticatedUserID,
+ "users": users ?? [user],
+ "server": ["id": "server", "name": "Mock", "accessToken": "mock", "connections": []],
+ "activeSessions": ["playing", "paused"].enumerated().map { index, state in
+ ["sessionKey": "session-\(index)", "userID": 11, "deviceID": sessionDeviceID,
+ "state": state, "mediaType": "movie", "mediaID": "movie-1"] as [String: Any]
+ },
+ "historyEvents": [event], "libraries": [],
+ "artwork": [["path": "/avatar.png", "resource": "avatar.png"]],
+ ]
+ return try JSONDecoder().decode(PlexMockServerPayload.self, from: JSONSerialization.data(withJSONObject: object))
+ }
+
+ @Test func identityAndDeviceAreSharedWhilePlaybackStateStaysIndependent() throws {
+ let payload = try payload()
+ try payload.validateProfiles()
+ let user = try #require(payload.users.first)
+ let device = try #require(user.devices.first)
+ #expect(user.materialize().thumb == user.materializeUser().thumb)
+ #expect(user.materializeAuthenticatedUser().thumb == user.avatar)
+ #expect(user.materializeAuthenticatedUser().email == "elliot@example.com")
+ #expect(user.materializeAuthenticatedUser().title == user.name)
+ #expect(payload.activeSessions.map { device.materializePlayer(state: $0.state).state } == ["playing", "paused"])
+ #expect(device.materializePlayer(state: "playing").title == "Iceweasel")
+ #expect(device.materializePlayer(state: "playing").remotePublicAddress == "203.0.113.24")
+ #expect(device.connection.sessionLocation == "wan")
+ #expect(device.materializeHistoryDevice().id == payload.historyEvents.first?.deviceID)
+ #expect(device.materializeHistoryDevice().platform == "Linux")
+ }
+
+ @Test func rejectsMissingAuthenticationAndActivityReferences() throws {
+ #expect(throws: PlexMockServerPayload.ProfileError.self) { try payload(authenticatedUserID: 99).validateProfiles() }
+ #expect(throws: PlexMockServerPayload.ProfileError.self) { try payload(sessionDeviceID: 99).validateProfiles() }
+ #expect(throws: PlexMockServerPayload.ProfileError.self) { try payload(historyDeviceID: 99).validateProfiles() }
+ try payload(historyDeviceID: nil).validateProfiles()
+ }
+
+ @Test func rejectsAnotherUsersDeviceAndDuplicateGlobalIdentities() throws {
+ var otherDevice = device
+ otherDevice["id"] = 2
+ otherDevice["machineIdentifier"] = "other-browser"
+ var otherUser = user
+ otherUser["id"] = 12
+ otherUser["devices"] = [otherDevice]
+ #expect(throws: PlexMockServerPayload.ProfileError.self) {
+ try payload(users: [user, otherUser], sessionDeviceID: 2).validateProfiles()
+ }
+ #expect(throws: PlexMockServerPayload.ProfileError.self) {
+ try payload(users: [user, otherUser], historyDeviceID: 2).validateProfiles()
+ }
+ otherUser["devices"] = [device]
+ #expect(throws: PlexMockServerPayload.ProfileError.self) { try payload(users: [user, otherUser]).validateProfiles() }
+ #expect(throws: PlexMockServerPayload.ProfileError.self) { try payload(users: [user, user]).validateProfiles() }
+ otherDevice["machineIdentifier"] = device["machineIdentifier"]
+ otherUser["devices"] = [otherDevice]
+ #expect(throws: PlexMockServerPayload.ProfileError.self) { try payload(users: [user, otherUser]).validateProfiles() }
+ }
+
+ @Test func rejectsMissingAvatarsAndConflictingIPLocations() throws {
+ var changed = user
+ changed["avatar"] = "/missing.png"
+ #expect(throws: PlexMockServerPayload.ProfileError.self) { try payload(users: [changed]).validateProfiles() }
+ var otherDevice = device
+ otherDevice["id"] = 2
+ otherDevice["machineIdentifier"] = "second-browser"
+ otherDevice["connection"] = ["remotePublicAddress": "203.0.113.24", "resolvedLocation": "Paris, France"]
+ changed = user
+ changed["devices"] = [device, otherDevice]
+ #expect(throws: PlexMockServerPayload.ProfileError.self) { try payload(users: [changed]).validateProfiles() }
+ }
+
+ @Test func profileRoundTripPreservesUnknownConnectionValues() throws {
+ var user = try #require(payload().users.first)
+ user.devices[0].connection = .init()
+ let encoded = try JSONEncoder().encode(user)
+ let decoded = try JSONDecoder().decode(PlexMockServerPayload.User.self, from: encoded)
+ #expect(decoded == user)
+ #expect(decoded.devices[0].connection.sessionLocation == nil)
+ #expect(decoded.devices[0].materializePlayer(state: nil).secure == nil)
+ #expect(decoded.devices[0].materializePlayer(state: nil).local == nil)
+ }
+ @Test func nameDrivesAllDisplayValuesAndFallsBackToUsernameWhenBlank() throws {
+ var user = try #require(payload().users.first)
+ for name in ["Baumer", "", " ", nil] as [String?] {
+ user.friendlyName = name
+ let expected = name == "Baumer" ? "Baumer" : "elliot"
+ #expect(user.materialize().name == expected)
+ #expect(user.materializeUser().title == expected)
+ #expect(user.materializeAuthenticatedUser().title == expected)
+ #expect(user.materializeAuthenticatedUser().username == "elliot")
+ }
+ let object = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(user)) as? [String: Any])
+ #expect(object["name"] == nil)
+ }
+
+}
diff --git a/Tests/PlexBarTests/PlexSessionTests.swift b/Packages/PlexData/Tests/PlexModelsTests/PlexSessionTests.swift
similarity index 99%
rename from Tests/PlexBarTests/PlexSessionTests.swift
rename to Packages/PlexData/Tests/PlexModelsTests/PlexSessionTests.swift
index e80399b..b7d4563 100644
--- a/Tests/PlexBarTests/PlexSessionTests.swift
+++ b/Packages/PlexData/Tests/PlexModelsTests/PlexSessionTests.swift
@@ -1,6 +1,6 @@
+import PlexModels
import Foundation
import Testing
-@testable import PlexBar
@Test func normalizesProductVersionForDisplay() async throws {
let server = PlexServerResource(
@@ -41,6 +41,7 @@ import Testing
)
#expect(session.posterPath == "/library/metadata/show-thumb")
+ #expect(session.detailLine == "S51 • E17 - Colman Domingo; Anitta")
}
@Test func classifiesMovieSessionFromExplicitType() async throws {
diff --git a/PlexBar.xcodeproj/project.pbxproj b/PlexBar.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..3b38566
--- /dev/null
+++ b/PlexBar.xcodeproj/project.pbxproj
@@ -0,0 +1,1557 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 77;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 21776749D52636039276396B /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 02F89628F9045FAD68D161C4 /* Sparkle */; };
+ 28BE6D5FA2FBCE656F77EC7C /* PlexMockData in Frameworks */ = {isa = PBXBuildFile; productRef = 74B9F2D4927FF133174DBF62 /* PlexMockData */; };
+ 4A9100010000000000000010 /* PlexURLBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000014 /* PlexURLBuilder.swift */; };
+ 4A9100010000000000000011 /* PlexPlaybackMarker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000015 /* PlexPlaybackMarker.swift */; };
+ 4A9100010000000000000013 /* PlexHub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000017 /* PlexHub.swift */; };
+ 4A9100010000000000000019 /* AppConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A910001000000000000001D /* AppConstants.swift */; };
+ 4A910001000000000000001A /* PlexRemoteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A910001000000000000001E /* PlexRemoteService.swift */; };
+ 4A910001000000000000001B /* PlexClientContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A910001000000000000001F /* PlexClientContext.swift */; };
+ 4A910001000000000000001C /* PlexRequestBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000020 /* PlexRequestBuilder.swift */; };
+ 4A9100010000000000000021 /* PlexJWT.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000028 /* PlexJWT.swift */; };
+ 4A9100010000000000000022 /* KeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000029 /* KeychainStore.swift */; };
+ 4A9100010000000000000026 /* PlexAuthClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A910001000000000000002D /* PlexAuthClient.swift */; };
+ 4A9100010000000000000027 /* PlexDeviceIdentityStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A910001000000000000002E /* PlexDeviceIdentityStore.swift */; };
+ 4A910001000000000000002F /* PlexBarTVAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4A910001000000000000003C /* PlexBarTVAssets.xcassets */; };
+ 4A9100010000000000000030 /* ribbon-balloon.png in Resources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000031 /* ribbon-balloon.png */; };
+ 4A9100010000000000000032 /* NativePlaybackCapabilityProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000037 /* NativePlaybackCapabilityProbe.swift */; };
+ 4A9100010000000000000033 /* PlexAutoplayPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000038 /* PlexAutoplayPreferences.swift */; };
+ 4A9100010000000000000034 /* PlexPlayback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000039 /* PlexPlayback.swift */; };
+ 4A9100010000000000000035 /* PlexPlaybackStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A910001000000000000003A /* PlexPlaybackStatus.swift */; };
+ 4A9100010000000000000036 /* PlexPlaybackRequestParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A910001000000000000003B /* PlexPlaybackRequestParameters.swift */; };
+ 4A910001000000000000003D /* PlexEpisodeContinuity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A910001000000000000003E /* PlexEpisodeContinuity.swift */; };
+ 4A910001000000000000003F /* PlexTimelineReportCadence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000040 /* PlexTimelineReportCadence.swift */; };
+ 4A9100010000000000000041 /* PlexTimelineRequestParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000042 /* PlexTimelineRequestParameters.swift */; };
+ 4A9100010000000000000044 /* PlexPlaybackDecisionResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000043 /* PlexPlaybackDecisionResolver.swift */; };
+ 4A9100010000000000000046 /* PlexAccountJWTManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000045 /* PlexAccountJWTManager.swift */; };
+ 4A9100010000000000000048 /* PlexPlaybackChapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000047 /* PlexPlaybackChapter.swift */; };
+ 4A910001000000000000004A /* PlexMediaSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000049 /* PlexMediaSelection.swift */; };
+ 4A910001000000000000004C /* PlexNativeMediaFacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A910001000000000000004B /* PlexNativeMediaFacts.swift */; };
+ 4A910001000000000000004E /* PlexPlaybackInfoPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A910001000000000000004D /* PlexPlaybackInfoPresentation.swift */; };
+ 4A9100010000000000000050 /* PlexNativePlaybackSpeedConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A910001000000000000004F /* PlexNativePlaybackSpeedConfiguration.swift */; };
+ 4A9100010000000000000052 /* PlexNativeVideoScalingConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000051 /* PlexNativeVideoScalingConfiguration.swift */; };
+ 4A9100010000000000000054 /* PlexPlaybackMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000053 /* PlexPlaybackMetrics.swift */; };
+ 4A9100010000000000000056 /* PlexPlaybackQualitySuggestions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000055 /* PlexPlaybackQualitySuggestions.swift */; };
+ 4A9100010000000000000057 /* PlexAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000058 /* PlexAPIError.swift */; };
+ 4A9100010000000000000059 /* PlexPlayQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A910001000000000000005A /* PlexPlayQueue.swift */; };
+ 4A910001000000000000005B /* PlexMediaProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A910001000000000000005C /* PlexMediaProvider.swift */; };
+ 4A910001000000000000005D /* PlexMediaSourceURI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A910001000000000000005E /* PlexMediaSourceURI.swift */; };
+ 4A910001000000000000005F /* PlexContinuousPlayQueueRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000060 /* PlexContinuousPlayQueueRequest.swift */; };
+ 4A9100010000000000000061 /* PlexNowPlayingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000062 /* PlexNowPlayingController.swift */; };
+ 4A9100010000000000000063 /* PlexRewindOnResume.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000064 /* PlexRewindOnResume.swift */; };
+ 4A9100010000000000000065 /* PlexPlaybackSleepTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000066 /* PlexPlaybackSleepTimer.swift */; };
+ 4A9100010000000000000068 /* PlexMotion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000067 /* PlexMotion.swift */; };
+ 4A9100010000000000000069 /* PlexImageDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A910001000000000000006A /* PlexImageDecoder.swift */; };
+ 4A910001000000000000006B /* PlexBoundedConcurrentMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A910001000000000000006C /* PlexBoundedConcurrentMap.swift */; };
+ 4A910001000000000000006D /* PlexMediaRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A910001000000000000006E /* PlexMediaRoute.swift */; };
+ 4A910001000000000000006F /* PlexCastAndCrewPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000070 /* PlexCastAndCrewPresentation.swift */; };
+ 4A9100010000000000000071 /* PlexCinematicBackdrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000072 /* PlexCinematicBackdrop.swift */; };
+ 4A9100010000000000000073 /* PlexExternalRatingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000074 /* PlexExternalRatingsView.swift */; };
+ 4A9100010000000000000075 /* PlexExternalRatingsPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000076 /* PlexExternalRatingsPresentation.swift */; };
+ 4A9100010000000000000077 /* PlexSeasonPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000078 /* PlexSeasonPicker.swift */; };
+ 4A9100010000000000000079 /* PlexMediaArtworkPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A910001000000000000007A /* PlexMediaArtworkPresentation.swift */; };
+ 4A910001000000000000007B /* PlexMediaWatchStateIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A910001000000000000007C /* PlexMediaWatchStateIndicator.swift */; };
+ 4A910001000000000000007D /* PlexMediaMetadataPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A910001000000000000007E /* PlexMediaMetadataPresentation.swift */; };
+ 4A910001000000000000007F /* PlexPhotoPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000080 /* PlexPhotoPresentation.swift */; };
+ 4A9100010000000000000081 /* PlexMediaMetadataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000082 /* PlexMediaMetadataView.swift */; };
+ 4A9100010000000000000083 /* PlexMediaSummaryPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000084 /* PlexMediaSummaryPresentation.swift */; };
+ 4A9100010000000000000085 /* PlexLibraryBrowse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9100010000000000000086 /* PlexLibraryBrowse.swift */; };
+ 4A9100040000000000000001 /* PlexBarTopShelf.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 4A9100040000000000000002 /* PlexBarTopShelf.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+ 616319DB60A240FC1D3A903C /* Studio/Resources/StudioAppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 4AC74A1E45840705ADB71373 /* Studio/Resources/StudioAppIcon.icon */; };
+ 82B6635D2C318F88250A909A /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 7CDC00102320A7D463E2E701 /* AppIcon.icon */; };
+ A0662DAB4AAD3B509E41A120 /* PlexModels in Frameworks */ = {isa = PBXBuildFile; productRef = CD7B401562F37D35DB570D70 /* PlexModels */; };
+ F8906F1A8CD993966F8B8432 /* PlexModels in Frameworks */ = {isa = PBXBuildFile; productRef = 363F28FF569E5DF67D40D117 /* PlexModels */; };
+ FDCE46DEE599FE682434E418 /* PlexMockData in Frameworks */ = {isa = PBXBuildFile; productRef = A2B76E2EB8524D2385A991DF /* PlexMockData */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+ 34A9BCBEE81F75550B5DEC3A /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 99D12365F8058E74529B496C /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 04D69605D17997F2671F5859;
+ remoteInfo = PlexBar;
+ };
+ 451658F772C1A2F824B1C5D9 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 99D12365F8058E74529B496C /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 8BCF4B6C1803661DC52399CD;
+ remoteInfo = PlexBarStudio;
+ };
+ 4A910002000000000000000A /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 99D12365F8058E74529B496C /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 4A9100010000000000000005;
+ remoteInfo = PlexBarTV;
+ };
+ 4A910003000000000000000A /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 99D12365F8058E74529B496C /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 4A9100010000000000000005;
+ remoteInfo = PlexBarTV;
+ };
+ 4A9100040000000000000006 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 99D12365F8058E74529B496C /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 4A9100040000000000000008;
+ remoteInfo = PlexBarTopShelf;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ 4A9100040000000000000005 /* Embed App Extensions */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 13;
+ files = (
+ 4A9100040000000000000001 /* PlexBarTopShelf.appex in Embed App Extensions */,
+ );
+ name = "Embed App Extensions";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
+/* Begin PBXFileReference section */
+ 285B312F1F94E0B792064D1B /* PlexBar.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PlexBar.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 3D3723B83A95EE51D1089ED0 /* Studio/README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = Studio/README.md; sourceTree = SOURCE_ROOT; };
+ 474F60C985C4E8F172664AF3 /* PlexBar Studio.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; path = "PlexBar Studio.app"; sourceTree = BUILT_PRODUCTS_DIR; };
+ 4A9100010000000000000001 /* PlexBarTV.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PlexBarTV.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 4A9100010000000000000002 /* PlexBarTV-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "PlexBarTV-Info.plist"; sourceTree = ""; };
+ 4A9100010000000000000014 /* PlexURLBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexURLBuilder.swift; path = PlexBar/Support/PlexURLBuilder.swift; sourceTree = SOURCE_ROOT; };
+ 4A9100010000000000000015 /* PlexPlaybackMarker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexPlaybackMarker.swift; path = PlexBar/Models/PlexPlaybackMarker.swift; sourceTree = SOURCE_ROOT; };
+ 4A9100010000000000000017 /* PlexHub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexHub.swift; path = PlexBar/Models/PlexHub.swift; sourceTree = SOURCE_ROOT; };
+ 4A910001000000000000001D /* AppConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppConstants.swift; path = PlexBar/Support/AppConstants.swift; sourceTree = SOURCE_ROOT; };
+ 4A910001000000000000001E /* PlexRemoteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexRemoteService.swift; path = PlexBar/Support/PlexRemoteService.swift; sourceTree = SOURCE_ROOT; };
+ 4A910001000000000000001F /* PlexClientContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexClientContext.swift; path = PlexBar/Support/PlexClientContext.swift; sourceTree = SOURCE_ROOT; };
+ 4A9100010000000000000020 /* PlexRequestBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexRequestBuilder.swift; path = PlexBar/Support/PlexRequestBuilder.swift; sourceTree = SOURCE_ROOT; };
+ 4A9100010000000000000028 /* PlexJWT.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexJWT.swift; path = PlexBar/Support/PlexJWT.swift; sourceTree = SOURCE_ROOT; };
+ 4A9100010000000000000029 /* KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = KeychainStore.swift; path = PlexBar/Support/KeychainStore.swift; sourceTree = SOURCE_ROOT; };
+ 4A910001000000000000002D /* PlexAuthClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexAuthClient.swift; path = PlexBar/Services/PlexAuthClient.swift; sourceTree = SOURCE_ROOT; };
+ 4A910001000000000000002E /* PlexDeviceIdentityStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexDeviceIdentityStore.swift; path = PlexBar/Services/PlexDeviceIdentityStore.swift; sourceTree = SOURCE_ROOT; };
+ 4A9100010000000000000031 /* ribbon-balloon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = "ribbon-balloon.png"; path = "PlexBar/Resources/AppIcon.icon/Assets/ribbon-balloon.png"; sourceTree = SOURCE_ROOT; };
+ 4A9100010000000000000037 /* NativePlaybackCapabilityProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = NativePlaybackCapabilityProbe.swift; path = PlexBar/Support/NativePlaybackCapabilityProbe.swift; sourceTree = SOURCE_ROOT; };
+ 4A9100010000000000000038 /* PlexAutoplayPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexAutoplayPreferences.swift; path = PlexBar/Support/PlexAutoplayPreferences.swift; sourceTree = SOURCE_ROOT; };
+ 4A9100010000000000000039 /* PlexPlayback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexPlayback.swift; path = PlexBar/Models/PlexPlayback.swift; sourceTree = SOURCE_ROOT; };
+ 4A910001000000000000003A /* PlexPlaybackStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexPlaybackStatus.swift; path = PlexBar/Models/PlexPlaybackStatus.swift; sourceTree = SOURCE_ROOT; };
+ 4A910001000000000000003B /* PlexPlaybackRequestParameters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexPlaybackRequestParameters.swift; path = PlexBar/Playback/PlexPlaybackRequestParameters.swift; sourceTree = SOURCE_ROOT; };
+ 4A910001000000000000003C /* PlexBarTVAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = PlexBarTVAssets.xcassets; path = PlexBar/Resources/PlexBarTVAssets.xcassets; sourceTree = SOURCE_ROOT; };
+ 4A910001000000000000003E /* PlexEpisodeContinuity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexEpisodeContinuity.swift; path = PlexBar/Models/PlexEpisodeContinuity.swift; sourceTree = SOURCE_ROOT; };
+ 4A9100010000000000000040 /* PlexTimelineReportCadence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexTimelineReportCadence.swift; path = PlexBar/Playback/PlexTimelineReportCadence.swift; sourceTree = SOURCE_ROOT; };
+ 4A9100010000000000000042 /* PlexTimelineRequestParameters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexTimelineRequestParameters.swift; path = PlexBar/Playback/PlexTimelineRequestParameters.swift; sourceTree = SOURCE_ROOT; };
+ 4A9100010000000000000043 /* PlexPlaybackDecisionResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexPlaybackDecisionResolver.swift; path = PlexBar/Playback/PlexPlaybackDecisionResolver.swift; sourceTree = SOURCE_ROOT; };
+ 4A9100010000000000000045 /* PlexAccountJWTManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexAccountJWTManager.swift; path = PlexBar/Stores/PlexAccountJWTManager.swift; sourceTree = SOURCE_ROOT; };
+ 4A9100010000000000000047 /* PlexPlaybackChapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexPlaybackChapter.swift; path = PlexBar/Models/PlexPlaybackChapter.swift; sourceTree = SOURCE_ROOT; };
+ 4A9100010000000000000049 /* PlexMediaSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexMediaSelection.swift; path = PlexBar/Models/PlexMediaSelection.swift; sourceTree = SOURCE_ROOT; };
+ 4A910001000000000000004B /* PlexNativeMediaFacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexNativeMediaFacts.swift; path = PlexBar/Playback/PlexNativeMediaFacts.swift; sourceTree = SOURCE_ROOT; };
+ 4A910001000000000000004D /* PlexPlaybackInfoPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexPlaybackInfoPresentation.swift; path = PlexBar/Playback/PlexPlaybackInfoPresentation.swift; sourceTree = SOURCE_ROOT; };
+ 4A910001000000000000004F /* PlexNativePlaybackSpeedConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexNativePlaybackSpeedConfiguration.swift; path = PlexBar/Playback/PlexNativePlaybackSpeedConfiguration.swift; sourceTree = SOURCE_ROOT; };
+ 4A9100010000000000000051 /* PlexNativeVideoScalingConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexNativeVideoScalingConfiguration.swift; path = PlexBar/Playback/PlexNativeVideoScalingConfiguration.swift; sourceTree = SOURCE_ROOT; };
+ 4A9100010000000000000053 /* PlexPlaybackMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexPlaybackMetrics.swift; path = PlexBar/Playback/PlexPlaybackMetrics.swift; sourceTree = SOURCE_ROOT; };
+ 4A9100010000000000000055 /* PlexPlaybackQualitySuggestions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexPlaybackQualitySuggestions.swift; path = PlexBar/Playback/PlexPlaybackQualitySuggestions.swift; sourceTree = SOURCE_ROOT; };
+ 4A9100010000000000000058 /* PlexAPIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexAPIError.swift; path = PlexBar/Support/PlexAPIError.swift; sourceTree = SOURCE_ROOT; };
+ 4A910001000000000000005A /* PlexPlayQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexPlayQueue.swift; path = PlexBar/Models/PlexPlayQueue.swift; sourceTree = SOURCE_ROOT; };
+ 4A910001000000000000005C /* PlexMediaProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexMediaProvider.swift; path = PlexBar/Models/PlexMediaProvider.swift; sourceTree = SOURCE_ROOT; };
+ 4A910001000000000000005E /* PlexMediaSourceURI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexMediaSourceURI.swift; path = PlexBar/Support/PlexMediaSourceURI.swift; sourceTree = SOURCE_ROOT; };
+ 4A9100010000000000000060 /* PlexContinuousPlayQueueRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexContinuousPlayQueueRequest.swift; path = PlexBar/Playback/PlexContinuousPlayQueueRequest.swift; sourceTree = SOURCE_ROOT; };
+ 4A9100010000000000000062 /* PlexNowPlayingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexNowPlayingController.swift; path = PlexBar/Playback/PlexNowPlayingController.swift; sourceTree = SOURCE_ROOT; };
+ 4A9100010000000000000064 /* PlexRewindOnResume.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexRewindOnResume.swift; path = PlexBar/Support/PlexRewindOnResume.swift; sourceTree = SOURCE_ROOT; };
+ 4A9100010000000000000066 /* PlexPlaybackSleepTimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexPlaybackSleepTimer.swift; path = PlexBar/Support/PlexPlaybackSleepTimer.swift; sourceTree = SOURCE_ROOT; };
+ 4A9100010000000000000067 /* PlexMotion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexMotion.swift; path = PlexBar/Support/PlexMotion.swift; sourceTree = SOURCE_ROOT; };
+ 4A910001000000000000006A /* PlexImageDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexImageDecoder.swift; path = PlexBar/Support/PlexImageDecoder.swift; sourceTree = SOURCE_ROOT; };
+ 4A910001000000000000006C /* PlexBoundedConcurrentMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexBoundedConcurrentMap.swift; path = PlexBar/Support/PlexBoundedConcurrentMap.swift; sourceTree = SOURCE_ROOT; };
+ 4A910001000000000000006E /* PlexMediaRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexMediaRoute.swift; path = PlexBar/Models/PlexMediaRoute.swift; sourceTree = SOURCE_ROOT; };
+ 4A9100010000000000000070 /* PlexCastAndCrewPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexCastAndCrewPresentation.swift; path = PlexBar/Support/PlexCastAndCrewPresentation.swift; sourceTree = SOURCE_ROOT; };
+ 4A9100010000000000000072 /* PlexCinematicBackdrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexCinematicBackdrop.swift; path = PlexBar/Views/PlexCinematicBackdrop.swift; sourceTree = SOURCE_ROOT; };
+ 4A9100010000000000000074 /* PlexExternalRatingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexExternalRatingsView.swift; path = PlexBar/Views/PlexExternalRatingsView.swift; sourceTree = SOURCE_ROOT; };
+ 4A9100010000000000000076 /* PlexExternalRatingsPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexExternalRatingsPresentation.swift; path = PlexBar/Support/PlexExternalRatingsPresentation.swift; sourceTree = SOURCE_ROOT; };
+ 4A9100010000000000000078 /* PlexSeasonPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexSeasonPicker.swift; path = PlexBar/Views/PlexSeasonPicker.swift; sourceTree = SOURCE_ROOT; };
+ 4A910001000000000000007A /* PlexMediaArtworkPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexMediaArtworkPresentation.swift; path = PlexBar/Support/PlexMediaArtworkPresentation.swift; sourceTree = SOURCE_ROOT; };
+ 4A910001000000000000007C /* PlexMediaWatchStateIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexMediaWatchStateIndicator.swift; path = PlexBar/Views/PlexMediaWatchStateIndicator.swift; sourceTree = SOURCE_ROOT; };
+ 4A910001000000000000007E /* PlexMediaMetadataPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexMediaMetadataPresentation.swift; path = PlexBar/Support/PlexMediaMetadataPresentation.swift; sourceTree = SOURCE_ROOT; };
+ 4A9100010000000000000080 /* PlexPhotoPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexPhotoPresentation.swift; path = PlexBar/Support/PlexPhotoPresentation.swift; sourceTree = SOURCE_ROOT; };
+ 4A9100010000000000000082 /* PlexMediaMetadataView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexMediaMetadataView.swift; path = PlexBar/Views/PlexMediaMetadataView.swift; sourceTree = SOURCE_ROOT; };
+ 4A9100010000000000000084 /* PlexMediaSummaryPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexMediaSummaryPresentation.swift; path = PlexBar/Support/PlexMediaSummaryPresentation.swift; sourceTree = SOURCE_ROOT; };
+ 4A9100010000000000000086 /* PlexLibraryBrowse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlexLibraryBrowse.swift; path = PlexBar/Models/PlexLibraryBrowse.swift; sourceTree = SOURCE_ROOT; };
+ 4A9100020000000000000001 /* PlexBarTVTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PlexBarTVTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ 4A9100030000000000000001 /* PlexBarTVLiveUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PlexBarTVLiveUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ 4A9100040000000000000002 /* PlexBarTopShelf.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = PlexBarTopShelf.appex; sourceTree = BUILT_PRODUCTS_DIR; };
+ 4A9100040000000000000010 /* PlexBarTopShelf-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "PlexBarTopShelf-Info.plist"; sourceTree = ""; };
+ 4A9100040000000000000011 /* PlexBarTopShelf.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PlexBarTopShelf.entitlements; sourceTree = ""; };
+ 4AC74A1E45840705ADB71373 /* Studio/Resources/StudioAppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = Studio/Resources/StudioAppIcon.icon; sourceTree = SOURCE_ROOT; };
+ 55582C8CE850F9EB45BFD8EF /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; };
+ 61A2A1FA3B9E45FEAA010001 /* Shared.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Shared.xcconfig; sourceTree = ""; };
+ 61A2A1FA3B9E45FEAA010002 /* PlexBar-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "PlexBar-Info.plist"; sourceTree = ""; };
+ 63DF1D3DAEA28FEB5EF88D3B /* PlexBarStudioTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; path = PlexBarStudioTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ 6C1BD9EA5C3A412C2DFB43F4 /* Studio/Config/Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Studio/Config/Info.plist; sourceTree = SOURCE_ROOT; };
+ 7CDC00102320A7D463E2E701 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = ""; };
+ AC67D9C1228E77749C552642 /* PlexBarTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PlexBarTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ CC0A13F0916C323FADBD3D6A /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFileSystemSynchronizedRootGroup section */
+ 03ED93ACE00F5908CC02E1A0 /* PlexBarTests */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = PlexBarTests;
+ sourceTree = "";
+ };
+ 0405E452E98457920E52A56E /* App */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ name = App;
+ path = Studio/App;
+ sourceTree = SOURCE_ROOT;
+ };
+ 0DCFEF7F4F154A9050FD8944 /* App */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = App;
+ sourceTree = "";
+ };
+ 26F20AD55CF3A593CDB96BF7 /* Support */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = Support;
+ sourceTree = "";
+ };
+ 4A9100010000000000000003 /* TV */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = TV;
+ sourceTree = "";
+ };
+ 4A910002000000000000000B /* TV Tests */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ name = "TV Tests";
+ path = PlexBarTests/TV;
+ sourceTree = SOURCE_ROOT;
+ };
+ 4A910003000000000000000B /* TV UI Tests */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ name = "TV UI Tests";
+ path = PlexBarTests/TVUI;
+ sourceTree = SOURCE_ROOT;
+ };
+ 4A9100040000000000000003 /* Top Shelf Shared */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ name = "Top Shelf Shared";
+ path = PlexBar/TopShelf/Shared;
+ sourceTree = SOURCE_ROOT;
+ };
+ 4A9100040000000000000004 /* Top Shelf Extension */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ name = "Top Shelf Extension";
+ path = PlexBar/TopShelf/Extension;
+ sourceTree = SOURCE_ROOT;
+ };
+ 4C720AFCEB5F3C6543BA76AE /* Playback */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = Playback;
+ sourceTree = "";
+ };
+ 4F1077371C8139CB9F36CC7E /* Models */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ name = Models;
+ path = Studio/Models;
+ sourceTree = SOURCE_ROOT;
+ };
+ 51FA3FC2E47C52796D98B3F7 /* StudioTests */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = StudioTests;
+ sourceTree = SOURCE_ROOT;
+ };
+ 6334903BBA050907FD560B95 /* Views */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ name = Views;
+ path = Studio/Views;
+ sourceTree = SOURCE_ROOT;
+ };
+ 6985C422E58A088B3C3CA4E6 /* MenuBarIcon */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = MenuBarIcon;
+ sourceTree = "";
+ };
+ 776F209A9CD0D5CE7FF72F42 /* Services */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ name = Services;
+ path = Studio/Services;
+ sourceTree = SOURCE_ROOT;
+ };
+ 8A7FD63E6AB58FC5F129B7BA /* Views */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = Views;
+ sourceTree = "";
+ };
+ A03BF67138E06E878C1B8961 /* Models */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = Models;
+ sourceTree = "";
+ };
+ B520D56C55BEA750908D98F7 /* Stores */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = Stores;
+ sourceTree = "";
+ };
+ BD0E158892A4498270836F39 /* Stores */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ name = Stores;
+ path = Studio/Stores;
+ sourceTree = SOURCE_ROOT;
+ };
+ E74C05AC8D13F749E011FC13 /* Services */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = Services;
+ sourceTree = "";
+ };
+/* End PBXFileSystemSynchronizedRootGroup section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 4A9100010000000000000004 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ F8906F1A8CD993966F8B8432 /* PlexModels in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 4A9100020000000000000005 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 4A9100030000000000000005 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 4A910004000000000000000A /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 4C2504C9A514499A22B11DC1 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 21776749D52636039276396B /* Sparkle in Frameworks */,
+ A0662DAB4AAD3B509E41A120 /* PlexModels in Frameworks */,
+ 28BE6D5FA2FBCE656F77EC7C /* PlexMockData in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 628CF362A6A779F92AE8AF4B /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ FDCE46DEE599FE682434E418 /* PlexMockData in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ C119DF02BF15674572190585 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 114C8CB3B607B12646F0A6DA /* Resources */ = {
+ isa = PBXGroup;
+ children = (
+ 7CDC00102320A7D463E2E701 /* AppIcon.icon */,
+ 4A910001000000000000003C /* PlexBarTVAssets.xcassets */,
+ 4A9100010000000000000031 /* ribbon-balloon.png */,
+ 6985C422E58A088B3C3CA4E6 /* MenuBarIcon */,
+ );
+ path = Resources;
+ sourceTree = "";
+ };
+ 239B6D1C7630E8AA6BF93A96 /* Config */ = {
+ isa = PBXGroup;
+ children = (
+ 6C1BD9EA5C3A412C2DFB43F4 /* Studio/Config/Info.plist */,
+ );
+ name = Config;
+ sourceTree = "";
+ };
+ 4A9100010000000000000018 /* TV Shared Core */ = {
+ isa = PBXGroup;
+ children = (
+ 4A910001000000000000001D /* AppConstants.swift */,
+ 4A9100010000000000000045 /* PlexAccountJWTManager.swift */,
+ 4A9100010000000000000058 /* PlexAPIError.swift */,
+ 4A9100010000000000000060 /* PlexContinuousPlayQueueRequest.swift */,
+ 4A910001000000000000003E /* PlexEpisodeContinuity.swift */,
+ 4A9100010000000000000070 /* PlexCastAndCrewPresentation.swift */,
+ 4A9100010000000000000072 /* PlexCinematicBackdrop.swift */,
+ 4A9100010000000000000076 /* PlexExternalRatingsPresentation.swift */,
+ 4A9100010000000000000074 /* PlexExternalRatingsView.swift */,
+ 4A9100010000000000000078 /* PlexSeasonPicker.swift */,
+ 4A910001000000000000007C /* PlexMediaWatchStateIndicator.swift */,
+ 4A9100010000000000000082 /* PlexMediaMetadataView.swift */,
+ 4A9100010000000000000080 /* PlexPhotoPresentation.swift */,
+ 4A910001000000000000007E /* PlexMediaMetadataPresentation.swift */,
+ 4A9100010000000000000084 /* PlexMediaSummaryPresentation.swift */,
+ 4A9100010000000000000086 /* PlexLibraryBrowse.swift */,
+ 4A910001000000000000007A /* PlexMediaArtworkPresentation.swift */,
+ 4A910001000000000000006E /* PlexMediaRoute.swift */,
+ 4A9100010000000000000049 /* PlexMediaSelection.swift */,
+ 4A910001000000000000005C /* PlexMediaProvider.swift */,
+ 4A910001000000000000005E /* PlexMediaSourceURI.swift */,
+ 4A910001000000000000004B /* PlexNativeMediaFacts.swift */,
+ 4A910001000000000000005A /* PlexPlayQueue.swift */,
+ 4A910001000000000000004D /* PlexPlaybackInfoPresentation.swift */,
+ 4A910001000000000000004F /* PlexNativePlaybackSpeedConfiguration.swift */,
+ 4A9100010000000000000051 /* PlexNativeVideoScalingConfiguration.swift */,
+ 4A9100010000000000000062 /* PlexNowPlayingController.swift */,
+ 4A9100010000000000000064 /* PlexRewindOnResume.swift */,
+ 4A9100010000000000000066 /* PlexPlaybackSleepTimer.swift */,
+ 4A9100010000000000000067 /* PlexMotion.swift */,
+ 4A9100010000000000000053 /* PlexPlaybackMetrics.swift */,
+ 4A9100010000000000000055 /* PlexPlaybackQualitySuggestions.swift */,
+ 4A9100010000000000000017 /* PlexHub.swift */,
+ 4A9100010000000000000015 /* PlexPlaybackMarker.swift */,
+ 4A9100010000000000000047 /* PlexPlaybackChapter.swift */,
+ 4A9100010000000000000039 /* PlexPlayback.swift */,
+ 4A9100010000000000000043 /* PlexPlaybackDecisionResolver.swift */,
+ 4A910001000000000000003B /* PlexPlaybackRequestParameters.swift */,
+ 4A910001000000000000003A /* PlexPlaybackStatus.swift */,
+ 4A9100010000000000000040 /* PlexTimelineReportCadence.swift */,
+ 4A9100010000000000000042 /* PlexTimelineRequestParameters.swift */,
+ 4A910001000000000000002D /* PlexAuthClient.swift */,
+ 4A910001000000000000001F /* PlexClientContext.swift */,
+ 4A910001000000000000002E /* PlexDeviceIdentityStore.swift */,
+ 4A9100010000000000000029 /* KeychainStore.swift */,
+ 4A9100010000000000000037 /* NativePlaybackCapabilityProbe.swift */,
+ 4A910001000000000000006A /* PlexImageDecoder.swift */,
+ 4A910001000000000000006C /* PlexBoundedConcurrentMap.swift */,
+ 4A9100010000000000000038 /* PlexAutoplayPreferences.swift */,
+ 4A9100010000000000000028 /* PlexJWT.swift */,
+ 4A9100010000000000000020 /* PlexRequestBuilder.swift */,
+ 4A910001000000000000001E /* PlexRemoteService.swift */,
+ 4A9100010000000000000014 /* PlexURLBuilder.swift */,
+ );
+ name = "TV Shared Core";
+ sourceTree = "";
+ };
+ 5C03A363087C558EB8A2C558 /* Studio */ = {
+ isa = PBXGroup;
+ children = (
+ 239B6D1C7630E8AA6BF93A96 /* Config */,
+ 0405E452E98457920E52A56E /* App */,
+ 4F1077371C8139CB9F36CC7E /* Models */,
+ 776F209A9CD0D5CE7FF72F42 /* Services */,
+ BD0E158892A4498270836F39 /* Stores */,
+ 6334903BBA050907FD560B95 /* Views */,
+ 4AC74A1E45840705ADB71373 /* Studio/Resources/StudioAppIcon.icon */,
+ 3D3723B83A95EE51D1089ED0 /* Studio/README.md */,
+ );
+ name = Studio;
+ sourceTree = "";
+ };
+ 7A97BF8BBC5CBA40A384FF4D = {
+ isa = PBXGroup;
+ children = (
+ 51FA3FC2E47C52796D98B3F7 /* StudioTests */,
+ 5C03A363087C558EB8A2C558 /* Studio */,
+ 9E099BCA65079B9FBC2E7CB4 /* Config */,
+ 800DB8D882F265AC68298805 /* PlexBar */,
+ 03ED93ACE00F5908CC02E1A0 /* PlexBarTests */,
+ 4A910002000000000000000B /* TV Tests */,
+ 4A910003000000000000000B /* TV UI Tests */,
+ F5232B90F32853C03738C047 /* Products */,
+ );
+ sourceTree = "";
+ };
+ 800DB8D882F265AC68298805 /* PlexBar */ = {
+ isa = PBXGroup;
+ children = (
+ 4A9100010000000000000003 /* TV */,
+ 4A9100010000000000000018 /* TV Shared Core */,
+ 4A9100040000000000000003 /* Top Shelf Shared */,
+ 4A9100040000000000000004 /* Top Shelf Extension */,
+ 0DCFEF7F4F154A9050FD8944 /* App */,
+ A03BF67138E06E878C1B8961 /* Models */,
+ 4C720AFCEB5F3C6543BA76AE /* Playback */,
+ 114C8CB3B607B12646F0A6DA /* Resources */,
+ E74C05AC8D13F749E011FC13 /* Services */,
+ B520D56C55BEA750908D98F7 /* Stores */,
+ 26F20AD55CF3A593CDB96BF7 /* Support */,
+ 8A7FD63E6AB58FC5F129B7BA /* Views */,
+ );
+ path = PlexBar;
+ sourceTree = "";
+ };
+ 9E099BCA65079B9FBC2E7CB4 /* Config */ = {
+ isa = PBXGroup;
+ children = (
+ 61A2A1FA3B9E45FEAA010002 /* PlexBar-Info.plist */,
+ 4A9100010000000000000002 /* PlexBarTV-Info.plist */,
+ 4A9100040000000000000010 /* PlexBarTopShelf-Info.plist */,
+ 4A9100040000000000000011 /* PlexBarTopShelf.entitlements */,
+ 61A2A1FA3B9E45FEAA010001 /* Shared.xcconfig */,
+ 55582C8CE850F9EB45BFD8EF /* Debug.xcconfig */,
+ CC0A13F0916C323FADBD3D6A /* Release.xcconfig */,
+ );
+ path = Config;
+ sourceTree = "";
+ };
+ F5232B90F32853C03738C047 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 63DF1D3DAEA28FEB5EF88D3B /* PlexBarStudioTests.xctest */,
+ 474F60C985C4E8F172664AF3 /* PlexBar Studio.app */,
+ 285B312F1F94E0B792064D1B /* PlexBar.app */,
+ 4A9100010000000000000001 /* PlexBarTV.app */,
+ 4A9100040000000000000002 /* PlexBarTopShelf.appex */,
+ AC67D9C1228E77749C552642 /* PlexBarTests.xctest */,
+ 4A9100020000000000000001 /* PlexBarTVTests.xctest */,
+ 4A9100030000000000000001 /* PlexBarTVLiveUITests.xctest */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 04D69605D17997F2671F5859 /* PlexBar */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 3713CB05E0E2A52573054DE0 /* Build configuration list for PBXNativeTarget "PlexBar" */;
+ buildPhases = (
+ DED0D4FA87F9A7FC16F3F216 /* Sources */,
+ 9CBAE774AA5967A3D87EA836 /* Resources */,
+ 4C2504C9A514499A22B11DC1 /* Frameworks */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ 0DCFEF7F4F154A9050FD8944 /* App */,
+ 26F20AD55CF3A593CDB96BF7 /* Support */,
+ 4C720AFCEB5F3C6543BA76AE /* Playback */,
+ 6985C422E58A088B3C3CA4E6 /* MenuBarIcon */,
+ 8A7FD63E6AB58FC5F129B7BA /* Views */,
+ A03BF67138E06E878C1B8961 /* Models */,
+ B520D56C55BEA750908D98F7 /* Stores */,
+ E74C05AC8D13F749E011FC13 /* Services */,
+ );
+ name = PlexBar;
+ packageProductDependencies = (
+ 02F89628F9045FAD68D161C4 /* Sparkle */,
+ CD7B401562F37D35DB570D70 /* PlexModels */,
+ 74B9F2D4927FF133174DBF62 /* PlexMockData */,
+ );
+ productName = PlexBar;
+ productReference = 285B312F1F94E0B792064D1B /* PlexBar.app */;
+ productType = "com.apple.product-type.application";
+ };
+ 4A9100010000000000000005 /* PlexBarTV */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 4A9100010000000000000009 /* Build configuration list for PBXNativeTarget "PlexBarTV" */;
+ buildPhases = (
+ 4A9100010000000000000006 /* Sources */,
+ 4A9100010000000000000007 /* Resources */,
+ 4A9100010000000000000004 /* Frameworks */,
+ 4A9100040000000000000005 /* Embed App Extensions */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 4A9100040000000000000007 /* PBXTargetDependency */,
+ );
+ fileSystemSynchronizedGroups = (
+ 4A9100010000000000000003 /* TV */,
+ 4A9100040000000000000003 /* Top Shelf Shared */,
+ );
+ name = PlexBarTV;
+ packageProductDependencies = (
+ 363F28FF569E5DF67D40D117 /* PlexModels */,
+ );
+ productName = PlexBarTV;
+ productReference = 4A9100010000000000000001 /* PlexBarTV.app */;
+ productType = "com.apple.product-type.application";
+ };
+ 4A9100020000000000000002 /* PlexBarTVTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 4A9100020000000000000006 /* Build configuration list for PBXNativeTarget "PlexBarTVTests" */;
+ buildPhases = (
+ 4A9100020000000000000003 /* Sources */,
+ 4A9100020000000000000004 /* Resources */,
+ 4A9100020000000000000005 /* Frameworks */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 4A9100020000000000000009 /* PBXTargetDependency */,
+ );
+ fileSystemSynchronizedGroups = (
+ 4A910002000000000000000B /* TV Tests */,
+ );
+ name = PlexBarTVTests;
+ productName = PlexBarTVTests;
+ productReference = 4A9100020000000000000001 /* PlexBarTVTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+ 4A9100030000000000000002 /* PlexBarTVLiveUITests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 4A9100030000000000000006 /* Build configuration list for PBXNativeTarget "PlexBarTVLiveUITests" */;
+ buildPhases = (
+ 4A9100030000000000000003 /* Sources */,
+ 4A9100030000000000000004 /* Resources */,
+ 4A9100030000000000000005 /* Frameworks */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 4A9100030000000000000009 /* PBXTargetDependency */,
+ );
+ fileSystemSynchronizedGroups = (
+ 4A910003000000000000000B /* TV UI Tests */,
+ );
+ name = PlexBarTVLiveUITests;
+ productName = PlexBarTVLiveUITests;
+ productReference = 4A9100030000000000000001 /* PlexBarTVLiveUITests.xctest */;
+ productType = "com.apple.product-type.bundle.ui-testing";
+ };
+ 4A9100040000000000000008 /* PlexBarTopShelf */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 4A910004000000000000000C /* Build configuration list for PBXNativeTarget "PlexBarTopShelf" */;
+ buildPhases = (
+ 4A9100040000000000000009 /* Sources */,
+ 4A910004000000000000000A /* Frameworks */,
+ 4A910004000000000000000B /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ 4A9100040000000000000003 /* Top Shelf Shared */,
+ 4A9100040000000000000004 /* Top Shelf Extension */,
+ );
+ name = PlexBarTopShelf;
+ productName = PlexBarTopShelf;
+ productReference = 4A9100040000000000000002 /* PlexBarTopShelf.appex */;
+ productType = "com.apple.product-type.app-extension";
+ };
+ 8BCF4B6C1803661DC52399CD /* PlexBarStudio */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = A2AB9A27D8D6D3B5C715EB8B /* Build configuration list for PBXNativeTarget "PlexBarStudio" */;
+ buildPhases = (
+ D2C952E15BB932D091F337D2 /* Sources */,
+ 6C721D7AFD19F01EE52C4131 /* Resources */,
+ 628CF362A6A779F92AE8AF4B /* Frameworks */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ 0405E452E98457920E52A56E /* App */,
+ 4F1077371C8139CB9F36CC7E /* Models */,
+ 6334903BBA050907FD560B95 /* Views */,
+ 776F209A9CD0D5CE7FF72F42 /* Services */,
+ BD0E158892A4498270836F39 /* Stores */,
+ );
+ name = PlexBarStudio;
+ packageProductDependencies = (
+ A2B76E2EB8524D2385A991DF /* PlexMockData */,
+ );
+ productName = "PlexBar Studio";
+ productReference = 474F60C985C4E8F172664AF3 /* PlexBar Studio.app */;
+ productType = "com.apple.product-type.application";
+ };
+ 9D84969CABB037494AEFBCB7 /* PlexBarTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 8CF747782D1AEE7DBD7683E2 /* Build configuration list for PBXNativeTarget "PlexBarTests" */;
+ buildPhases = (
+ 55BD447D6F8C229A4C70FBB9 /* Sources */,
+ F71E088AD1F6D70F5178CFE2 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ B3E0B3D15C7A70CBB2AFB066 /* PBXTargetDependency */,
+ );
+ fileSystemSynchronizedGroups = (
+ 03ED93ACE00F5908CC02E1A0 /* PlexBarTests */,
+ );
+ name = PlexBarTests;
+ packageProductDependencies = (
+ );
+ productName = PlexBarTests;
+ productReference = AC67D9C1228E77749C552642 /* PlexBarTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+ F6DC3F201BBC0C43C7EF4669 /* PlexBarStudioTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 1D99DA20A5D95864D7306286 /* Build configuration list for PBXNativeTarget "PlexBarStudioTests" */;
+ buildPhases = (
+ AFE2B0D3D3BF80EBAB7CD71B /* Sources */,
+ 3AAC2A9D10A7637D9DDB23F7 /* Resources */,
+ C119DF02BF15674572190585 /* Frameworks */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ C8022F33EE399E59EA30543C /* PBXTargetDependency */,
+ );
+ fileSystemSynchronizedGroups = (
+ 51FA3FC2E47C52796D98B3F7 /* StudioTests */,
+ );
+ name = PlexBarStudioTests;
+ productName = PlexBarStudioTests;
+ productReference = 63DF1D3DAEA28FEB5EF88D3B /* PlexBarStudioTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 99D12365F8058E74529B496C /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = YES;
+ LastUpgradeCheck = 2600;
+ TargetAttributes = {
+ 04D69605D17997F2671F5859 = {
+ ProvisioningStyle = Automatic;
+ };
+ 4A9100010000000000000005 = {
+ ProvisioningStyle = Automatic;
+ };
+ 9D84969CABB037494AEFBCB7 = {
+ ProvisioningStyle = Automatic;
+ };
+ };
+ };
+ buildConfigurationList = 00142C26111F8B0B5754534D /* Build configuration list for PBXProject "PlexBar" */;
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ Base,
+ en,
+ );
+ mainGroup = 7A97BF8BBC5CBA40A384FF4D;
+ minimizedProjectReferenceProxies = 1;
+ packageReferences = (
+ 7F550155B4988B4FB8D4CE93 /* XCRemoteSwiftPackageReference "Sparkle" */,
+ CEFCCC9487832BBAE1978FB3 /* XCLocalSwiftPackageReference "Packages/PlexData" */,
+ );
+ preferredProjectObjectVersion = 77;
+ productRefGroup = F5232B90F32853C03738C047 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 8BCF4B6C1803661DC52399CD /* PlexBarStudio */,
+ F6DC3F201BBC0C43C7EF4669 /* PlexBarStudioTests */,
+ 4A9100040000000000000008 /* PlexBarTopShelf */,
+ 4A9100020000000000000002 /* PlexBarTVTests */,
+ 4A9100030000000000000002 /* PlexBarTVLiveUITests */,
+ 04D69605D17997F2671F5859 /* PlexBar */,
+ 4A9100010000000000000005 /* PlexBarTV */,
+ 9D84969CABB037494AEFBCB7 /* PlexBarTests */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 3AAC2A9D10A7637D9DDB23F7 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 4A9100010000000000000007 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 4A910001000000000000002F /* PlexBarTVAssets.xcassets in Resources */,
+ 4A9100010000000000000030 /* ribbon-balloon.png in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 4A9100020000000000000004 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 4A9100030000000000000004 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 4A910004000000000000000B /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 6C721D7AFD19F01EE52C4131 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 616319DB60A240FC1D3A903C /* Studio/Resources/StudioAppIcon.icon in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 9CBAE774AA5967A3D87EA836 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 82B6635D2C318F88250A909A /* AppIcon.icon in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ F71E088AD1F6D70F5178CFE2 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 4A9100010000000000000006 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 4A9100010000000000000019 /* AppConstants.swift in Sources */,
+ 4A9100010000000000000026 /* PlexAuthClient.swift in Sources */,
+ 4A9100010000000000000010 /* PlexURLBuilder.swift in Sources */,
+ 4A9100010000000000000027 /* PlexDeviceIdentityStore.swift in Sources */,
+ 4A9100010000000000000022 /* KeychainStore.swift in Sources */,
+ 4A9100010000000000000021 /* PlexJWT.swift in Sources */,
+ 4A9100010000000000000032 /* NativePlaybackCapabilityProbe.swift in Sources */,
+ 4A9100010000000000000033 /* PlexAutoplayPreferences.swift in Sources */,
+ 4A9100010000000000000034 /* PlexPlayback.swift in Sources */,
+ 4A9100010000000000000044 /* PlexPlaybackDecisionResolver.swift in Sources */,
+ 4A9100010000000000000046 /* PlexAccountJWTManager.swift in Sources */,
+ 4A9100010000000000000057 /* PlexAPIError.swift in Sources */,
+ 4A9100010000000000000036 /* PlexPlaybackRequestParameters.swift in Sources */,
+ 4A910001000000000000005F /* PlexContinuousPlayQueueRequest.swift in Sources */,
+ 4A910001000000000000003D /* PlexEpisodeContinuity.swift in Sources */,
+ 4A9100010000000000000035 /* PlexPlaybackStatus.swift in Sources */,
+ 4A910001000000000000003F /* PlexTimelineReportCadence.swift in Sources */,
+ 4A9100010000000000000041 /* PlexTimelineRequestParameters.swift in Sources */,
+ 4A9100010000000000000048 /* PlexPlaybackChapter.swift in Sources */,
+ 4A910001000000000000004A /* PlexMediaSelection.swift in Sources */,
+ 4A910001000000000000005B /* PlexMediaProvider.swift in Sources */,
+ 4A910001000000000000005D /* PlexMediaSourceURI.swift in Sources */,
+ 4A910001000000000000004C /* PlexNativeMediaFacts.swift in Sources */,
+ 4A9100010000000000000059 /* PlexPlayQueue.swift in Sources */,
+ 4A910001000000000000004E /* PlexPlaybackInfoPresentation.swift in Sources */,
+ 4A9100010000000000000050 /* PlexNativePlaybackSpeedConfiguration.swift in Sources */,
+ 4A9100010000000000000052 /* PlexNativeVideoScalingConfiguration.swift in Sources */,
+ 4A9100010000000000000061 /* PlexNowPlayingController.swift in Sources */,
+ 4A9100010000000000000063 /* PlexRewindOnResume.swift in Sources */,
+ 4A9100010000000000000065 /* PlexPlaybackSleepTimer.swift in Sources */,
+ 4A9100010000000000000068 /* PlexMotion.swift in Sources */,
+ 4A9100010000000000000069 /* PlexImageDecoder.swift in Sources */,
+ 4A910001000000000000006B /* PlexBoundedConcurrentMap.swift in Sources */,
+ 4A9100010000000000000054 /* PlexPlaybackMetrics.swift in Sources */,
+ 4A9100010000000000000056 /* PlexPlaybackQualitySuggestions.swift in Sources */,
+ 4A9100010000000000000011 /* PlexPlaybackMarker.swift in Sources */,
+ 4A910001000000000000006D /* PlexMediaRoute.swift in Sources */,
+ 4A910001000000000000006F /* PlexCastAndCrewPresentation.swift in Sources */,
+ 4A9100010000000000000071 /* PlexCinematicBackdrop.swift in Sources */,
+ 4A9100010000000000000075 /* PlexExternalRatingsPresentation.swift in Sources */,
+ 4A9100010000000000000073 /* PlexExternalRatingsView.swift in Sources */,
+ 4A9100010000000000000077 /* PlexSeasonPicker.swift in Sources */,
+ 4A910001000000000000007B /* PlexMediaWatchStateIndicator.swift in Sources */,
+ 4A9100010000000000000081 /* PlexMediaMetadataView.swift in Sources */,
+ 4A910001000000000000007F /* PlexPhotoPresentation.swift in Sources */,
+ 4A910001000000000000007D /* PlexMediaMetadataPresentation.swift in Sources */,
+ 4A9100010000000000000083 /* PlexMediaSummaryPresentation.swift in Sources */,
+ 4A9100010000000000000085 /* PlexLibraryBrowse.swift in Sources */,
+ 4A9100010000000000000079 /* PlexMediaArtworkPresentation.swift in Sources */,
+ 4A9100010000000000000013 /* PlexHub.swift in Sources */,
+ 4A910001000000000000001A /* PlexRemoteService.swift in Sources */,
+ 4A910001000000000000001B /* PlexClientContext.swift in Sources */,
+ 4A910001000000000000001C /* PlexRequestBuilder.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 4A9100020000000000000003 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 4A9100030000000000000003 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 4A9100040000000000000009 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 55BD447D6F8C229A4C70FBB9 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ AFE2B0D3D3BF80EBAB7CD71B /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ D2C952E15BB932D091F337D2 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ DED0D4FA87F9A7FC16F3F216 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ 4A9100020000000000000009 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 4A9100010000000000000005 /* PlexBarTV */;
+ targetProxy = 4A910002000000000000000A /* PBXContainerItemProxy */;
+ };
+ 4A9100030000000000000009 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 4A9100010000000000000005 /* PlexBarTV */;
+ targetProxy = 4A910003000000000000000A /* PBXContainerItemProxy */;
+ };
+ 4A9100040000000000000007 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 4A9100040000000000000008 /* PlexBarTopShelf */;
+ targetProxy = 4A9100040000000000000006 /* PBXContainerItemProxy */;
+ };
+ B3E0B3D15C7A70CBB2AFB066 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 04D69605D17997F2671F5859 /* PlexBar */;
+ targetProxy = 34A9BCBEE81F75550B5DEC3A /* PBXContainerItemProxy */;
+ };
+ C8022F33EE399E59EA30543C /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 8BCF4B6C1803661DC52399CD /* PlexBarStudio */;
+ targetProxy = 451658F772C1A2F824B1C5D9 /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin XCBuildConfiguration section */
+ 0197C65E32738B635F535D97 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ COMBINE_HIDPI_IMAGES = YES;
+ DEVELOPMENT_TEAM = T778QMSML9;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = "";
+ PRODUCT_BUNDLE_IDENTIFIER = com.crapshack.PlexBarTests;
+ PRODUCT_NAME = PlexBarTests;
+ SDKROOT = macosx;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/PlexBar.app/Contents/MacOS/PlexBar";
+ };
+ name = Debug;
+ };
+ 33F0DF2CB98550AD274E8064 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ COMBINE_HIDPI_IMAGES = YES;
+ DEVELOPMENT_TEAM = T778QMSML9;
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.video";
+ MARKETING_VERSION = 0.9.0;
+ PRODUCT_NAME = PlexBar;
+ SDKROOT = macosx;
+ };
+ name = Debug;
+ };
+ 3768C17D702A9DC18FF3B1B6 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = StudioAppIcon;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = T778QMSML9;
+ ENABLE_APP_SANDBOX = NO;
+ ENABLE_HARDENED_RUNTIME = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = Studio/Config/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = "PlexBar Studio";
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.crapshack.PlexBarStudio;
+ PRODUCT_MODULE_NAME = PlexBarStudio;
+ PRODUCT_NAME = "PlexBar Studio";
+ SDKROOT = macosx;
+ SUPPORTED_PLATFORMS = macosx;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited)";
+ SWIFT_STRICT_CONCURRENCY = complete;
+ SWIFT_VERSION = 6.0;
+ };
+ name = Debug;
+ };
+ 4A910001000000000000000A /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_ENTITLEMENTS = Config/PlexBarTV.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = T778QMSML9;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = "Config/PlexBarTV-Info.plist";
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.video";
+ MARKETING_VERSION = 1.0.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.crapshack.PlexBar.tv;
+ PRODUCT_NAME = PlexBarTV;
+ SDKROOT = appletvos;
+ SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_STRICT_CONCURRENCY = complete;
+ SWIFT_VERSION = 6.0;
+ TARGETED_DEVICE_FAMILY = 3;
+ TVOS_DEPLOYMENT_TARGET = 26.0;
+ };
+ name = Debug;
+ };
+ 4A910001000000000000000B /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_ENTITLEMENTS = Config/PlexBarTV.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = T778QMSML9;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = "Config/PlexBarTV-Info.plist";
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.video";
+ MARKETING_VERSION = 1.0.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.crapshack.PlexBar.tv;
+ PRODUCT_NAME = PlexBarTV;
+ SDKROOT = appletvos;
+ SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_STRICT_CONCURRENCY = complete;
+ SWIFT_VERSION = 6.0;
+ TARGETED_DEVICE_FAMILY = 3;
+ TVOS_DEPLOYMENT_TARGET = 26.0;
+ };
+ name = Release;
+ };
+ 4A9100020000000000000007 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ DEVELOPMENT_TEAM = T778QMSML9;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = "";
+ PRODUCT_BUNDLE_IDENTIFIER = com.crapshack.PlexBar.tv.tests;
+ PRODUCT_NAME = PlexBarTVTests;
+ SDKROOT = appletvos;
+ SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_VERSION = 6.0;
+ TARGETED_DEVICE_FAMILY = 3;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/PlexBarTV.app/PlexBarTV";
+ TVOS_DEPLOYMENT_TARGET = 26.0;
+ };
+ name = Debug;
+ };
+ 4A9100020000000000000008 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ DEVELOPMENT_TEAM = T778QMSML9;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = "";
+ PRODUCT_BUNDLE_IDENTIFIER = com.crapshack.PlexBar.tv.tests;
+ PRODUCT_NAME = PlexBarTVTests;
+ SDKROOT = appletvos;
+ SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_VERSION = 6.0;
+ TARGETED_DEVICE_FAMILY = 3;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/PlexBarTV.app/PlexBarTV";
+ TVOS_DEPLOYMENT_TARGET = 26.0;
+ };
+ name = Release;
+ };
+ 4A9100030000000000000007 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ DEVELOPMENT_TEAM = T778QMSML9;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = "";
+ PRODUCT_BUNDLE_IDENTIFIER = com.crapshack.PlexBar.tv.uitests;
+ PRODUCT_NAME = PlexBarTVLiveUITests;
+ SDKROOT = appletvos;
+ SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_VERSION = 6.0;
+ TARGETED_DEVICE_FAMILY = 3;
+ TEST_TARGET_NAME = PlexBarTV;
+ TVOS_DEPLOYMENT_TARGET = 26.0;
+ };
+ name = Debug;
+ };
+ 4A9100030000000000000008 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ DEVELOPMENT_TEAM = T778QMSML9;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = "";
+ PRODUCT_BUNDLE_IDENTIFIER = com.crapshack.PlexBar.tv.uitests;
+ PRODUCT_NAME = PlexBarTVLiveUITests;
+ SDKROOT = appletvos;
+ SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_VERSION = 6.0;
+ TARGETED_DEVICE_FAMILY = 3;
+ TEST_TARGET_NAME = PlexBarTV;
+ TVOS_DEPLOYMENT_TARGET = 26.0;
+ };
+ name = Release;
+ };
+ 4A910004000000000000000D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ APPLICATION_EXTENSION_API_ONLY = YES;
+ CODE_SIGN_ENTITLEMENTS = Config/PlexBarTopShelf.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = T778QMSML9;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = "Config/PlexBarTopShelf-Info.plist";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ MARKETING_VERSION = 1.0.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.crapshack.PlexBar.tv.topshelf;
+ PRODUCT_NAME = PlexBarTopShelf;
+ SDKROOT = appletvos;
+ SKIP_INSTALL = YES;
+ SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_STRICT_CONCURRENCY = complete;
+ SWIFT_VERSION = 6.0;
+ TARGETED_DEVICE_FAMILY = 3;
+ TVOS_DEPLOYMENT_TARGET = 26.0;
+ };
+ name = Debug;
+ };
+ 4A910004000000000000000E /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ APPLICATION_EXTENSION_API_ONLY = YES;
+ CODE_SIGN_ENTITLEMENTS = Config/PlexBarTopShelf.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = T778QMSML9;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = "Config/PlexBarTopShelf-Info.plist";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ MARKETING_VERSION = 1.0.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.crapshack.PlexBar.tv.topshelf;
+ PRODUCT_NAME = PlexBarTopShelf;
+ SDKROOT = appletvos;
+ SKIP_INSTALL = YES;
+ SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_STRICT_CONCURRENCY = complete;
+ SWIFT_VERSION = 6.0;
+ TARGETED_DEVICE_FAMILY = 3;
+ TVOS_DEPLOYMENT_TARGET = 26.0;
+ };
+ name = Release;
+ };
+ 4CDEF4B7DA4A50B05F1AEBF8 /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = CC0A13F0916C323FADBD3D6A /* Release.xcconfig */;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ SDKROOT = macosx;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ };
+ name = Release;
+ };
+ 98E9CD57E7762B32012F1B11 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ COMBINE_HIDPI_IMAGES = YES;
+ DEVELOPMENT_TEAM = T778QMSML9;
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.video";
+ MARKETING_VERSION = 0.9.0;
+ PRODUCT_NAME = PlexBar;
+ SDKROOT = macosx;
+ };
+ name = Release;
+ };
+ BB2A5D8F9D38A73FA994460F /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = "";
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = T778QMSML9;
+ ENABLE_APP_SANDBOX = NO;
+ ENABLE_HARDENED_RUNTIME = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = "";
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.crapshack.PlexBarStudioTests;
+ PRODUCT_MODULE_NAME = PlexBarStudioTests;
+ PRODUCT_NAME = PlexBarStudioTests;
+ SDKROOT = macosx;
+ SUPPORTED_PLATFORMS = macosx;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited)";
+ SWIFT_STRICT_CONCURRENCY = complete;
+ SWIFT_VERSION = 6.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/PlexBar Studio.app/Contents/MacOS/PlexBar Studio";
+ };
+ name = Release;
+ };
+ C842FD5E7808F39B6A55D99C /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ COMBINE_HIDPI_IMAGES = YES;
+ DEVELOPMENT_TEAM = T778QMSML9;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = "";
+ PRODUCT_BUNDLE_IDENTIFIER = com.crapshack.PlexBarTests;
+ PRODUCT_NAME = PlexBarTests;
+ SDKROOT = macosx;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/PlexBar.app/Contents/MacOS/PlexBar";
+ };
+ name = Release;
+ };
+ CBE68FF918B98EC5C411BDD5 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = StudioAppIcon;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = T778QMSML9;
+ ENABLE_APP_SANDBOX = NO;
+ ENABLE_HARDENED_RUNTIME = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = Studio/Config/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = "PlexBar Studio";
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.crapshack.PlexBarStudio;
+ PRODUCT_MODULE_NAME = PlexBarStudio;
+ PRODUCT_NAME = "PlexBar Studio";
+ SDKROOT = macosx;
+ SUPPORTED_PLATFORMS = macosx;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited)";
+ SWIFT_STRICT_CONCURRENCY = complete;
+ SWIFT_VERSION = 6.0;
+ };
+ name = Release;
+ };
+ F1B72FE58B78E9783744C883 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 55582C8CE850F9EB45BFD8EF /* Debug.xcconfig */;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "$(inherited)",
+ "DEBUG=1",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ SDKROOT = macosx;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ FE72D8565FB85960CE1CA6CD /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = "";
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = T778QMSML9;
+ ENABLE_APP_SANDBOX = NO;
+ ENABLE_HARDENED_RUNTIME = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = "";
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.crapshack.PlexBarStudioTests;
+ PRODUCT_MODULE_NAME = PlexBarStudioTests;
+ PRODUCT_NAME = PlexBarStudioTests;
+ SDKROOT = macosx;
+ SUPPORTED_PLATFORMS = macosx;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited)";
+ SWIFT_STRICT_CONCURRENCY = complete;
+ SWIFT_VERSION = 6.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/PlexBar Studio.app/Contents/MacOS/PlexBar Studio";
+ };
+ name = Debug;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 00142C26111F8B0B5754534D /* Build configuration list for PBXProject "PlexBar" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ F1B72FE58B78E9783744C883 /* Debug */,
+ 4CDEF4B7DA4A50B05F1AEBF8 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
+ 1D99DA20A5D95864D7306286 /* Build configuration list for PBXNativeTarget "PlexBarStudioTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ FE72D8565FB85960CE1CA6CD /* Debug */,
+ BB2A5D8F9D38A73FA994460F /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 3713CB05E0E2A52573054DE0 /* Build configuration list for PBXNativeTarget "PlexBar" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 33F0DF2CB98550AD274E8064 /* Debug */,
+ 98E9CD57E7762B32012F1B11 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
+ 4A9100010000000000000009 /* Build configuration list for PBXNativeTarget "PlexBarTV" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 4A910001000000000000000A /* Debug */,
+ 4A910001000000000000000B /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
+ 4A9100020000000000000006 /* Build configuration list for PBXNativeTarget "PlexBarTVTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 4A9100020000000000000007 /* Debug */,
+ 4A9100020000000000000008 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
+ 4A9100030000000000000006 /* Build configuration list for PBXNativeTarget "PlexBarTVLiveUITests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 4A9100030000000000000007 /* Debug */,
+ 4A9100030000000000000008 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
+ 4A910004000000000000000C /* Build configuration list for PBXNativeTarget "PlexBarTopShelf" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 4A910004000000000000000D /* Debug */,
+ 4A910004000000000000000E /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 8CF747782D1AEE7DBD7683E2 /* Build configuration list for PBXNativeTarget "PlexBarTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 0197C65E32738B635F535D97 /* Debug */,
+ C842FD5E7808F39B6A55D99C /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
+ A2AB9A27D8D6D3B5C715EB8B /* Build configuration list for PBXNativeTarget "PlexBarStudio" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 3768C17D702A9DC18FF3B1B6 /* Debug */,
+ CBE68FF918B98EC5C411BDD5 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+
+/* Begin XCLocalSwiftPackageReference section */
+ CEFCCC9487832BBAE1978FB3 /* XCLocalSwiftPackageReference "Packages/PlexData" */ = {
+ isa = XCLocalSwiftPackageReference;
+ relativePath = Packages/PlexData;
+ };
+/* End XCLocalSwiftPackageReference section */
+
+/* Begin XCRemoteSwiftPackageReference section */
+ 7F550155B4988B4FB8D4CE93 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
+ isa = XCRemoteSwiftPackageReference;
+ repositoryURL = "https://github.com/sparkle-project/Sparkle";
+ requirement = {
+ kind = upToNextMajorVersion;
+ minimumVersion = 2.9.1;
+ };
+ };
+/* End XCRemoteSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ 02F89628F9045FAD68D161C4 /* Sparkle */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = 7F550155B4988B4FB8D4CE93 /* XCRemoteSwiftPackageReference "Sparkle" */;
+ productName = Sparkle;
+ };
+ 363F28FF569E5DF67D40D117 /* PlexModels */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = PlexModels;
+ };
+ 74B9F2D4927FF133174DBF62 /* PlexMockData */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = PlexMockData;
+ };
+ A2B76E2EB8524D2385A991DF /* PlexMockData */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = PlexMockData;
+ };
+ CD7B401562F37D35DB570D70 /* PlexModels */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = PlexModels;
+ };
+/* End XCSwiftPackageProductDependency section */
+ };
+ rootObject = 99D12365F8058E74529B496C /* Project object */;
+}
diff --git a/PlexBar.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/PlexBar.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/PlexBar.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/Package.resolved b/PlexBar.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
similarity index 100%
rename from Package.resolved
rename to PlexBar.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
diff --git a/PlexBar.xcodeproj/xcshareddata/xcschemes/PlexBar Mock.xcscheme b/PlexBar.xcodeproj/xcshareddata/xcschemes/PlexBar Mock.xcscheme
new file mode 100644
index 0000000..f424e7c
--- /dev/null
+++ b/PlexBar.xcodeproj/xcshareddata/xcschemes/PlexBar Mock.xcscheme
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/PlexBar.xcodeproj/xcshareddata/xcschemes/PlexBar.xcscheme b/PlexBar.xcodeproj/xcshareddata/xcschemes/PlexBar.xcscheme
new file mode 100644
index 0000000..f4a72ec
--- /dev/null
+++ b/PlexBar.xcodeproj/xcshareddata/xcschemes/PlexBar.xcscheme
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/PlexBar.xcodeproj/xcshareddata/xcschemes/PlexBarStudio.xcscheme b/PlexBar.xcodeproj/xcshareddata/xcschemes/PlexBarStudio.xcscheme
new file mode 100644
index 0000000..59974b9
--- /dev/null
+++ b/PlexBar.xcodeproj/xcshareddata/xcschemes/PlexBarStudio.xcscheme
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/PlexBar.xcodeproj/xcshareddata/xcschemes/PlexBarTV.xcscheme b/PlexBar.xcodeproj/xcshareddata/xcschemes/PlexBarTV.xcscheme
new file mode 100644
index 0000000..58ec437
--- /dev/null
+++ b/PlexBar.xcodeproj/xcshareddata/xcschemes/PlexBarTV.xcscheme
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/PlexBar.xcodeproj/xcshareddata/xcschemes/PlexBarTVLiveUI.xcscheme b/PlexBar.xcodeproj/xcshareddata/xcschemes/PlexBarTVLiveUI.xcscheme
new file mode 100644
index 0000000..19a388f
--- /dev/null
+++ b/PlexBar.xcodeproj/xcshareddata/xcschemes/PlexBarTVLiveUI.xcscheme
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/PlexBar/App/PlexBarApp.swift b/PlexBar/App/PlexBarApp.swift
new file mode 100644
index 0000000..fb94e05
--- /dev/null
+++ b/PlexBar/App/PlexBarApp.swift
@@ -0,0 +1,154 @@
+import SwiftUI
+
+@main
+struct PlexBarApp: App {
+ @State private var settingsStore: PlexSettingsStore
+ @State private var connectionStore: PlexConnectionStore
+ @State private var authStore: PlexAuthStore
+ @State private var sessionStore: PlexSessionStore
+ @State private var historyStore: PlexHistoryStore
+ @State private var libraryStore: PlexLibraryStore
+ @State private var browserStore: PlexBrowserStore
+ @State private var playerCoordinator: PlexPlayerCoordinator
+ @State private var mainNavigationStore: PlexMainNavigationStore
+ @State private var serverPreviewStore: PlexServerPreviewStore
+ @State private var downloadsStore: PlexDownloadsStore
+ private let systemLifecycleObserver: PlexSystemLifecycleObserver
+ private let userInteractionMonitor: PlexUserInteractionMonitor
+ private let updateService: PlexUpdateService
+
+ init() {
+ let runtime = PlexAppRuntime.current()
+ let settingsStore = runtime.settingsStore
+ let resolver = runtime.connectionResolver
+ let connectionStore = PlexConnectionStore(settings: settingsStore, resolver: resolver)
+ let sessionStore = PlexSessionStore(
+ connectionStore: connectionStore,
+ client: runtime.apiClient,
+ geoIPClient: runtime.geoIPClient,
+ eventsClient: runtime.sessionEventsClient
+ )
+ let libraryStore = PlexLibraryStore(connectionStore: connectionStore, client: runtime.apiClient)
+ let historyStore = PlexHistoryStore(
+ connectionStore: connectionStore,
+ libraryStore: libraryStore,
+ client: runtime.apiClient
+ )
+ let serverPreviewStore = PlexServerPreviewStore(client: runtime.apiClient, resolver: resolver)
+ let userInteractionStore = PlexUserInteractionStore()
+ let browserStore = PlexBrowserStore(
+ connectionStore: connectionStore,
+ client: runtime.apiClient
+ )
+ let authStore = PlexAuthStore(
+ settings: settingsStore,
+ connectionStore: connectionStore,
+ sessionStore: sessionStore,
+ historyStore: historyStore,
+ libraryStore: libraryStore,
+ client: runtime.authClient,
+ deviceIdentityStore: runtime.deviceIdentityStore
+ )
+ _settingsStore = State(initialValue: settingsStore)
+ _connectionStore = State(initialValue: connectionStore)
+ _sessionStore = State(initialValue: sessionStore)
+ _historyStore = State(initialValue: historyStore)
+ _libraryStore = State(initialValue: libraryStore)
+ _browserStore = State(initialValue: browserStore)
+ _playerCoordinator = State(initialValue: PlexPlayerCoordinator(
+ userInteractionStore: userInteractionStore,
+ bandwidthRegistry: runtime.playbackBandwidthRegistry
+ ))
+ _mainNavigationStore = State(initialValue: PlexMainNavigationStore())
+ _serverPreviewStore = State(initialValue: serverPreviewStore)
+ _authStore = State(initialValue: authStore)
+ systemLifecycleObserver = PlexSystemLifecycleObserver(
+ onWillSleep: { sessionStore.systemWillSleep() },
+ onDidWake: { sessionStore.systemDidWake() }
+ )
+ userInteractionMonitor = PlexUserInteractionMonitor(store: userInteractionStore)
+ updateService = PlexUpdateService()
+ let downloadCreationStore = PlexDownloadCreationStore(
+ authStore: authStore,
+ connectionStore: connectionStore,
+ libraryStore: libraryStore,
+ browserStore: browserStore,
+ transferCoordinator: runtime.downloadTransferCoordinator
+ )
+ _downloadsStore = State(initialValue: PlexDownloadsStore(
+ authStore: authStore,
+ connectionStore: connectionStore,
+ browserStore: browserStore,
+ client: runtime.apiClient,
+ creationStore: downloadCreationStore,
+ transferCoordinator: runtime.downloadTransferCoordinator,
+ packageStore: runtime.downloadPackageStore,
+ jobRegistry: runtime.downloadJobRegistry,
+ playbackRegistry: runtime.offlinePlaybackRegistry,
+ preparedAssetStore: runtime.downloadPreparedAssetStore
+ ))
+ }
+
+ var body: some Scene {
+ Window("PlexBar", id: PlexMainNavigationStore.windowID) {
+ PlexMainWindowView(
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ authStore: authStore,
+ sessionStore: sessionStore,
+ historyStore: historyStore,
+ libraryStore: libraryStore,
+ browserStore: browserStore,
+ playerCoordinator: playerCoordinator,
+ navigationStore: mainNavigationStore,
+ downloadsStore: downloadsStore
+ )
+ .frame(minWidth: 840, minHeight: 560)
+ .task {
+ await downloadsStore.start()
+ }
+ .environment(downloadsStore)
+ }
+ .defaultSize(width: 1_180, height: 760)
+ .defaultPosition(.center)
+ .windowResizability(.contentMinSize)
+ .windowToolbarStyle(.unified)
+ .commands {
+ SidebarCommands()
+ PlexMainWindowCommands()
+ PlexPlaybackCommands(
+ coordinator: playerCoordinator,
+ settingsStore: settingsStore
+ )
+ }
+
+ Settings {
+ SettingsView(
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ authStore: authStore,
+ previewStore: serverPreviewStore,
+ sessionStore: sessionStore,
+ historyStore: historyStore,
+ updateService: updateService
+ )
+ }
+ .defaultSize(width: 480, height: 520)
+ .windowResizability(.contentSize)
+
+ MenuBarExtra {
+ MenuBarContentView(
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ authStore: authStore,
+ sessionStore: sessionStore,
+ historyStore: historyStore,
+ libraryStore: libraryStore,
+ playerCoordinator: playerCoordinator
+ )
+ } label: {
+ MenuBarLabelView(streamCount: sessionStore.activeStreamCount)
+ }
+ .menuBarExtraStyle(.window)
+ }
+}
diff --git a/PlexBar/App/PlexMainWindowCommands.swift b/PlexBar/App/PlexMainWindowCommands.swift
new file mode 100644
index 0000000..6debe6f
--- /dev/null
+++ b/PlexBar/App/PlexMainWindowCommands.swift
@@ -0,0 +1,66 @@
+import SwiftUI
+
+struct PlexFocusedCommandAction {
+ let title: String
+ let isEnabled: Bool
+ let perform: () -> Void
+
+ init(
+ title: String,
+ isEnabled: Bool = true,
+ perform: @escaping () -> Void
+ ) {
+ self.title = title
+ self.isEnabled = isEnabled
+ self.perform = perform
+ }
+
+ func callAsFunction() {
+ perform()
+ }
+}
+
+extension FocusedValues {
+ @Entry var plexRefreshCommand: PlexFocusedCommandAction?
+ @Entry var plexSearchCommand: PlexFocusedCommandAction?
+ @Entry var plexPlayerInfoCommand: PlexFocusedCommandAction?
+ @Entry var plexPlayerUpNextCommand: PlexFocusedCommandAction?
+}
+
+struct PlexMainWindowCommands: Commands {
+ @FocusedValue(\.plexRefreshCommand) private var refreshCommand
+ @FocusedValue(\.plexSearchCommand) private var searchCommand
+ @FocusedValue(\.plexPlayerInfoCommand) private var playerInfoCommand
+ @FocusedValue(\.plexPlayerUpNextCommand) private var playerUpNextCommand
+
+ var body: some Commands {
+ CommandGroup(after: .toolbar) {
+ Divider()
+
+ Button(playerInfoCommand?.title ?? "Show Info") {
+ playerInfoCommand?()
+ }
+ .keyboardShortcut("i", modifiers: .command)
+ .disabled(playerInfoCommand?.isEnabled != true)
+
+ Button(playerUpNextCommand?.title ?? "Show Up Next") {
+ playerUpNextCommand?()
+ }
+ .disabled(playerUpNextCommand?.isEnabled != true)
+
+ Divider()
+
+ Button(searchCommand?.title ?? "Search") {
+ searchCommand?()
+ }
+ .keyboardShortcut("f", modifiers: .command)
+ .disabled(searchCommand?.isEnabled != true)
+
+ Button(refreshCommand?.title ?? "Refresh") {
+ refreshCommand?()
+ }
+ .keyboardShortcut("r", modifiers: .command)
+ .disabled(refreshCommand?.isEnabled != true)
+ }
+ }
+}
diff --git a/PlexBar/Models/PlexActivitySummary.swift b/PlexBar/Models/PlexActivitySummary.swift
new file mode 100644
index 0000000..bc2f937
--- /dev/null
+++ b/PlexBar/Models/PlexActivitySummary.swift
@@ -0,0 +1,138 @@
+import PlexModels
+import Foundation
+
+enum PlexSessionDeliveryMethod: CaseIterable {
+ case directPlay
+ case directStream
+ case transcoding
+ case unknown
+}
+
+struct PlexActivitySummary {
+ let streamCount: Int
+ private(set) var directPlayCount = 0
+ private(set) var directStreamCount = 0
+ private(set) var transcodingCount = 0
+ private(set) var unknownCount = 0
+ private(set) var reportedBandwidthCount = 0
+ private(set) var totalBandwidthKbps: Double = 0
+ private(set) var localBandwidthKbps: Double = 0
+ private(set) var remoteBandwidthKbps: Double = 0
+ private(set) var unknownLocationBandwidthKbps: Double = 0
+ private(set) var unknownLocationCount = 0
+
+ var hasPartialBandwidth: Bool {
+ reportedBandwidthCount > 0 && reportedBandwidthCount < streamCount
+ }
+
+ // The store owns canonical session identity and deduplication.
+ init(sessions: [PlexSession]) {
+ streamCount = sessions.count
+ for session in sessions {
+ switch session.deliveryMethod {
+ case .directPlay: directPlayCount += 1
+ case .directStream: directStreamCount += 1
+ case .transcoding: transcodingCount += 1
+ case .unknown: unknownCount += 1
+ }
+
+ guard let bandwidth = session.session?.bandwidth, bandwidth >= 0 else { continue }
+ reportedBandwidthCount += 1
+ let value = Double(bandwidth)
+ totalBandwidthKbps += value
+ switch session.session?.location?.lowercased() {
+ case "lan": localBandwidthKbps += value
+ case "wan": remoteBandwidthKbps += value
+ default:
+ unknownLocationBandwidthKbps += value
+ unknownLocationCount += 1
+ }
+ }
+ }
+
+ static func bandwidthText(kbps: Double, locale: Locale = .autoupdatingCurrent) -> String {
+ let mbps = kbps / 1_000
+ let format = FloatingPointFormatStyle.number
+ .precision(.fractionLength(0...1))
+ .rounded(rule: .toNearestOrAwayFromZero)
+ .locale(locale)
+ if mbps > 0 && mbps < 0.05 {
+ return "<\(0.1.formatted(format)) Mbps"
+ }
+ return "\(mbps.formatted(format)) Mbps"
+ }
+}
+
+extension PlexSession {
+ var deliveryMethod: PlexSessionDeliveryMethod {
+ // Live TV exposes its output decisions on TranscodeSession rather than
+ // necessarily on the source tracks (the same distinction Tautulli uses).
+ if isLive, let transcodeSession {
+ let decisions = [transcodeSession.videoDecision, transcodeSession.audioDecision]
+ .compactMap { $0?.nilIfBlank?.lowercased() }
+ return Self.deliveryMethod(for: decisions)
+ }
+
+ guard let activePart = activePlaybackPart else {
+ return .unknown
+ }
+
+ let partDecision = activePart.decision?.nilIfBlank?.lowercased()
+ // Direct play applies to the whole part. Its source can list multiple
+ // audio/subtitle tracks without identifying which the client selected.
+ if partDecision == "directplay" { return .directPlay }
+ guard partDecision == nil || partDecision == "transcode" else { return .unknown }
+
+ var decisions: [String] = []
+ for type in [1, 2] {
+ let candidates = (activePart.stream ?? []).filter {
+ $0.streamType == type && $0.selected != false
+ }
+ guard !candidates.isEmpty else { continue }
+ guard let stream = Self.activeItem(in: candidates, selected: \.selected) else {
+ return .unknown
+ }
+ // Plex omits the decision for direct-play tracks. Subtitle conversion
+ // does not determine whether the audio/video is transcoding.
+ decisions.append(stream.decision?.nilIfBlank?.lowercased() ?? "directplay")
+ }
+ // A transcode part without any audio/video conversion decision is incomplete.
+ if partDecision == "transcode", decisions.allSatisfy({ $0 == "directplay" }) {
+ return .unknown
+ }
+ return Self.deliveryMethod(for: decisions)
+ }
+
+ private static func deliveryMethod(for decisions: [String]) -> PlexSessionDeliveryMethod {
+ guard !decisions.isEmpty,
+ decisions.allSatisfy({ ["directplay", "direct play", "copy", "transcode"].contains($0) }) else {
+ return .unknown
+ }
+ if decisions.contains("transcode") { return .transcoding }
+ if decisions.contains("copy") { return .directStream }
+ return .directPlay
+ }
+
+ var activePlaybackPart: PlexPart? {
+ guard let media = Self.activeItem(in: media ?? [], selected: \.selected) else { return nil }
+ return Self.activeItem(in: media.part ?? [], selected: \.selected)
+ }
+
+ func activePlaybackStream(type: Int) -> PlexStream? {
+ let candidates = (activePlaybackPart?.stream ?? []).filter {
+ $0.streamType == type && $0.selected != false
+ && (type != 3 || $0.selected == true || $0.decision?.nilIfBlank != nil)
+ && $0.decision?.lowercased() != "ignore"
+ && $0.decision?.lowercased() != "none"
+ }
+ return Self.activeItem(in: candidates, selected: \.selected)
+ }
+
+ private static func activeItem- (in items: [Item], selected: KeyPath
- ) -> Item? {
+ let explicit = items.filter { $0[keyPath: selected] == true }
+ if explicit.count == 1 { return explicit[0] }
+ guard explicit.isEmpty else { return nil }
+ let eligible = items.filter { $0[keyPath: selected] != false }
+ return eligible.count == 1 ? eligible[0] : nil
+ }
+}
diff --git a/PlexBar/Models/PlexAutomaticDownloadRule.swift b/PlexBar/Models/PlexAutomaticDownloadRule.swift
new file mode 100644
index 0000000..1640317
--- /dev/null
+++ b/PlexBar/Models/PlexAutomaticDownloadRule.swift
@@ -0,0 +1,133 @@
+import PlexModels
+import Foundation
+
+enum PlexAutomaticDownloadPolicy: String, Codable, CaseIterable, Identifiable, Sendable {
+ case allEpisodes
+ case unwatchedEpisodes
+
+ var id: Self { self }
+
+ var title: String {
+ switch self {
+ case .allEpisodes: "All Episodes"
+ case .unwatchedEpisodes: "Unwatched Episodes"
+ }
+ }
+
+ func includes(_ item: PlexMediaItem) -> Bool {
+ guard item.type?.lowercased() == "episode" else { return false }
+ return self == .allEpisodes || !item.isWatched
+ }
+}
+
+struct PlexAutomaticDownloadRule: Codable, Equatable, Identifiable, Sendable {
+ let id: UUID
+ let accountID: Int
+ let serverIdentifier: String
+ let libraryID: String
+ let sourceRatingKey: String
+ let sourceChildrenPath: String
+ let sourceType: String
+ let title: String
+ let posterPath: String?
+ let policy: PlexAutomaticDownloadPolicy
+ let keepsUpToDate: Bool
+ let removesWatchedDownloads: Bool
+ let createdAt: Date
+ var lastRefreshedAt: Date?
+ var lastErrorMessage: String?
+}
+
+actor PlexAutomaticDownloadRuleRegistry {
+ private struct Document: Codable {
+ static let currentSchemaVersion = 1
+
+ let schemaVersion: Int
+ let rules: [PlexAutomaticDownloadRule]
+ }
+
+ private let rootURL: URL
+ private var cachedRules: [UUID: PlexAutomaticDownloadRule]?
+
+ init(rootURL: URL = PlexDownloadPackageStore.defaultRootURL()) {
+ self.rootURL = rootURL.standardizedFileURL.resolvingSymlinksInPath()
+ }
+
+ func rules() throws -> [PlexAutomaticDownloadRule] {
+ try loadIfNeeded().values.sorted(by: Self.sort)
+ }
+
+ func save(_ rule: PlexAutomaticDownloadRule) throws {
+ var rules = try loadIfNeeded()
+ rules[rule.id] = rule
+ try persist(rules)
+ cachedRules = rules
+ }
+
+ func remove(withID id: UUID) throws {
+ var rules = try loadIfNeeded()
+ guard rules.removeValue(forKey: id) != nil else { return }
+ try persist(rules)
+ cachedRules = rules
+ }
+
+ private func loadIfNeeded() throws -> [UUID: PlexAutomaticDownloadRule] {
+ if let cachedRules { return cachedRules }
+ guard FileManager.default.fileExists(atPath: registryURL.path) else {
+ cachedRules = [:]
+ return [:]
+ }
+ let document = try Self.decoder.decode(
+ Document.self,
+ from: Data(contentsOf: registryURL)
+ )
+ guard document.schemaVersion == Document.currentSchemaVersion,
+ Set(document.rules.map(\.id)).count == document.rules.count else {
+ throw PlexDownloadTransferError.registryUnavailable
+ }
+ let rules = Dictionary(uniqueKeysWithValues: document.rules.map { ($0.id, $0) })
+ cachedRules = rules
+ return rules
+ }
+
+ private func persist(_ rules: [UUID: PlexAutomaticDownloadRule]) throws {
+ try FileManager.default.createDirectory(
+ at: registryURL.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ let document = Document(
+ schemaVersion: Document.currentSchemaVersion,
+ rules: rules.values.sorted(by: Self.sort)
+ )
+ try Self.encoder.encode(document).write(to: registryURL, options: .atomic)
+ }
+
+ private var registryURL: URL {
+ rootURL
+ .appendingPathComponent("Rules", isDirectory: true)
+ .appendingPathComponent("automatic-downloads.json")
+ }
+
+ private static func sort(
+ _ lhs: PlexAutomaticDownloadRule,
+ _ rhs: PlexAutomaticDownloadRule
+ ) -> Bool {
+ if lhs.createdAt != rhs.createdAt {
+ return lhs.createdAt < rhs.createdAt
+ }
+ return lhs.id.uuidString < rhs.id.uuidString
+ }
+
+ private static var encoder: JSONEncoder {
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ encoder.outputFormatting = [.sortedKeys]
+ return encoder
+ }
+
+ private static var decoder: JSONDecoder {
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return decoder
+ }
+}
diff --git a/PlexBar/Models/PlexCollectionPlaylistMutation.swift b/PlexBar/Models/PlexCollectionPlaylistMutation.swift
new file mode 100644
index 0000000..db44aca
--- /dev/null
+++ b/PlexBar/Models/PlexCollectionPlaylistMutation.swift
@@ -0,0 +1,22 @@
+import PlexModels
+import Foundation
+
+enum PlexListMoveDirection: Sendable {
+ case up
+ case down
+}
+
+extension PlexMediaItem {
+ var playlistMediaType: String? {
+ switch type?.lowercased() {
+ case "movie", "show", "season", "episode", "clip":
+ "video"
+ case "artist", "album", "track":
+ "audio"
+ case "photo", "photoalbum":
+ "photo"
+ default:
+ nil
+ }
+ }
+}
diff --git a/PlexBar/Models/PlexDownloadAuthorization.swift b/PlexBar/Models/PlexDownloadAuthorization.swift
new file mode 100644
index 0000000..34d92aa
--- /dev/null
+++ b/PlexBar/Models/PlexDownloadAuthorization.swift
@@ -0,0 +1,75 @@
+import PlexModels
+import Foundation
+
+struct PlexDownloadAuthorization: Equatable, Sendable {
+ let accountHasDownloadsEntitlement: Bool
+ let serverAllowsSync: Bool?
+ let libraryAllowsSync: Bool?
+ let libraryProviderSupportsDownloads: Bool
+
+ init(
+ user: PlexAuthenticatedUser,
+ library: PlexLibrary,
+ providerEndpoints: PlexLibraryProviderEndpoints
+ ) {
+ accountHasDownloadsEntitlement = user.hasDownloadsAccountEntitlement
+ serverAllowsSync = providerEndpoints.serverAllowsSync
+ libraryAllowsSync = library.allowSync
+ libraryProviderSupportsDownloads = providerEndpoints.supportsDownloadSubscriptions
+ }
+
+ var isAuthorized: Bool {
+ accountHasDownloadsEntitlement
+ && serverAllowsSync == true
+ && libraryAllowsSync == true
+ && libraryProviderSupportsDownloads
+ }
+}
+
+struct PlexDownloadAuthorizationScope: Equatable, Sendable {
+ let accountID: Int
+ let serverIdentifier: String
+ let serverURL: URL
+ let libraryID: String
+ let providerIdentifier: String
+}
+
+struct PlexDownloadCreationAuthorization: Equatable, Sendable {
+ let scope: PlexDownloadAuthorizationScope
+ let facts: PlexDownloadAuthorization
+}
+
+enum PlexDownloadCreationAuthorizationError: LocalizedError, Equatable {
+ case signedOut
+ case missingServerIdentity
+ case unavailableLibrary
+ case accountNotEntitled
+ case serverDisallowsDownloads
+ case libraryDisallowsDownloads
+ case providerDisallowsDownloads
+ case authorizationExpired
+ case mismatchedTransfer
+
+ var errorDescription: String? {
+ switch self {
+ case .signedOut:
+ "Sign in to Plex before creating a download."
+ case .missingServerIdentity:
+ "Plex did not provide the selected server identity required for this download."
+ case .unavailableLibrary:
+ "The selected Plex library is no longer available."
+ case .accountNotEntitled:
+ "The current Plex account does not include Downloads."
+ case .serverDisallowsDownloads:
+ "The selected Plex server does not allow Downloads."
+ case .libraryDisallowsDownloads:
+ "The selected Plex library does not allow Downloads."
+ case .providerDisallowsDownloads:
+ "The selected Plex provider does not advertise Downloads."
+ case .authorizationExpired:
+ "The Plex account, server, or library changed before the download could start."
+ case .mismatchedTransfer:
+ "The prepared download does not belong to the authorized Plex server and library."
+ }
+ }
+}
diff --git a/PlexBar/Models/PlexDownloadHandoff.swift b/PlexBar/Models/PlexDownloadHandoff.swift
new file mode 100644
index 0000000..e0c6a40
--- /dev/null
+++ b/PlexBar/Models/PlexDownloadHandoff.swift
@@ -0,0 +1,43 @@
+import Foundation
+
+struct PlexDownloadHandoffManifest: Codable, Equatable, Sendable {
+ static let currentSchemaVersion = 1
+
+ let schemaVersion: Int
+ let transferID: UUID
+ let statusCode: Int
+ let contentType: String?
+ let suggestedFileExtension: String?
+ let completedAt: Date
+
+ init(
+ schemaVersion: Int = Self.currentSchemaVersion,
+ transferID: UUID,
+ statusCode: Int,
+ contentType: String?,
+ suggestedFileExtension: String?,
+ completedAt: Date = Date()
+ ) {
+ self.schemaVersion = schemaVersion
+ self.transferID = transferID
+ self.statusCode = statusCode
+ self.contentType = contentType
+ self.suggestedFileExtension = suggestedFileExtension
+ self.completedAt = completedAt
+ }
+}
+
+struct PlexDownloadHandoff: Equatable, Sendable {
+ let manifest: PlexDownloadHandoffManifest
+ let directoryURL: URL
+ let mediaURL: URL
+}
+
+enum PlexDownloadHandoffError: Error, Equatable, Sendable {
+ case invalidTransferIdentity
+ case invalidResponse
+ case serverStatus(Int)
+ case invalidTemporaryFile
+ case existingHandoffIsInvalid
+ case publicationFailed
+}
diff --git a/PlexBar/Models/PlexDownloadPackage.swift b/PlexBar/Models/PlexDownloadPackage.swift
new file mode 100644
index 0000000..f56506b
--- /dev/null
+++ b/PlexBar/Models/PlexDownloadPackage.swift
@@ -0,0 +1,130 @@
+import Foundation
+
+struct PlexDownloadPackageIdentity: Codable, Equatable, Sendable {
+ let packageID: UUID
+ let accountID: Int?
+ let serverIdentifier: String
+ let queueID: Int
+ let queueItemID: Int
+ let metadataKey: String
+ let ratingKey: String
+
+ init(
+ packageID: UUID = UUID(),
+ accountID: Int,
+ serverIdentifier: String,
+ queueID: Int,
+ queueItemID: Int,
+ metadataKey: String,
+ ratingKey: String
+ ) {
+ self.packageID = packageID
+ self.accountID = accountID
+ self.serverIdentifier = serverIdentifier
+ self.queueID = queueID
+ self.queueItemID = queueItemID
+ self.metadataKey = metadataKey
+ self.ratingKey = ratingKey
+ }
+}
+
+struct PlexDownloadPackageManifest: Codable, Equatable, Sendable {
+ static let currentSchemaVersion = 2
+
+ let schemaVersion: Int
+ let identity: PlexDownloadPackageIdentity
+ let title: String
+ let mediaType: String?
+ let mediaFileName: String
+ let mediaByteCount: Int64
+ let contentType: String?
+ let decisionFileName: String
+ let artworkFileName: String?
+ let completedAt: Date
+
+ init(
+ schemaVersion: Int = Self.currentSchemaVersion,
+ identity: PlexDownloadPackageIdentity,
+ title: String,
+ mediaType: String?,
+ mediaFileName: String,
+ mediaByteCount: Int64,
+ contentType: String?,
+ decisionFileName: String,
+ artworkFileName: String? = nil,
+ completedAt: Date
+ ) {
+ self.schemaVersion = schemaVersion
+ self.identity = identity
+ self.title = title
+ self.mediaType = mediaType
+ self.mediaFileName = mediaFileName
+ self.mediaByteCount = mediaByteCount
+ self.contentType = contentType
+ self.decisionFileName = decisionFileName
+ self.artworkFileName = artworkFileName
+ self.completedAt = completedAt
+ }
+}
+
+struct PlexDownloadPackage: Identifiable, Equatable, Sendable {
+ let manifest: PlexDownloadPackageManifest
+ let packageURL: URL
+ let mediaURL: URL
+ let decisionURL: URL
+ let artworkURL: URL?
+
+ var id: UUID {
+ manifest.identity.packageID
+ }
+}
+
+struct PlexDownloadPackageIntegrityIssue: Error, Equatable, Sendable {
+ enum Reason: Equatable, Sendable {
+ case unreadableManifest
+ case unsupportedSchemaVersion(Int)
+ case packageIdentityMismatch
+ case invalidManifest
+ case missingDecision
+ case missingMedia
+ case mediaSizeMismatch(expected: Int64, actual: Int64)
+ }
+
+ let packageURL: URL
+ let reason: Reason
+}
+
+struct PlexDownloadPackageReconciliation: Equatable, Sendable {
+ let packages: [PlexDownloadPackage]
+ let integrityIssues: [PlexDownloadPackageIntegrityIssue]
+ let removedStagingPackageCount: Int
+}
+
+enum PlexDownloadPackageStoreError: LocalizedError, Equatable {
+ case invalidIdentity
+ case invalidTitle
+ case invalidMediaFileExtension
+ case invalidDecision
+ case invalidDownloadedFile
+ case missingEmbeddedSubtitle
+ case invalidPackage
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidIdentity:
+ "Plex did not provide a valid download identity."
+ case .invalidTitle:
+ "Plex did not provide a title for this download."
+ case .invalidMediaFileExtension:
+ "Plex provided an invalid downloaded-media file extension."
+ case .invalidDecision:
+ "Plex did not provide a valid download decision."
+ case .invalidDownloadedFile:
+ "The completed download is not a regular media file."
+ case .missingEmbeddedSubtitle:
+ "The completed download is missing the subtitle track Plex promised."
+ case .invalidPackage:
+ "The downloaded media package is incomplete or invalid."
+ }
+ }
+}
diff --git a/PlexBar/Models/PlexDownloadPreferences.swift b/PlexBar/Models/PlexDownloadPreferences.swift
new file mode 100644
index 0000000..43e2055
--- /dev/null
+++ b/PlexBar/Models/PlexDownloadPreferences.swift
@@ -0,0 +1,251 @@
+import PlexModels
+import Foundation
+
+enum PlexDownloadVideoQuality: String, CaseIterable, Identifiable, Sendable {
+ case original
+ case fourK20Mbps
+ case fullHD12Mbps
+ case fullHD8Mbps
+ case hd4Mbps
+ case hd2Mbps
+ case sd1500Kbps
+
+ var id: Self { self }
+
+ var label: String {
+ switch self {
+ case .original: "Original"
+ case .fourK20Mbps: "4K · 20 Mbps"
+ case .fullHD12Mbps: "1080p · 12 Mbps"
+ case .fullHD8Mbps: "1080p · 8 Mbps"
+ case .hd4Mbps: "720p · 4 Mbps"
+ case .hd2Mbps: "720p · 2 Mbps"
+ case .sd1500Kbps: "480p · 1.5 Mbps"
+ }
+ }
+
+ fileprivate var constraints: PlexDownloadVideoQualityConstraints? {
+ switch self {
+ case .original:
+ nil
+ case .fourK20Mbps:
+ PlexDownloadVideoQualityConstraints(width: 3_840, height: 2_160, bitrate: 20_000)
+ case .fullHD12Mbps:
+ PlexDownloadVideoQualityConstraints(width: 1_920, height: 1_080, bitrate: 12_000)
+ case .fullHD8Mbps:
+ PlexDownloadVideoQualityConstraints(width: 1_920, height: 1_080, bitrate: 8_000)
+ case .hd4Mbps:
+ PlexDownloadVideoQualityConstraints(width: 1_280, height: 720, bitrate: 4_000)
+ case .hd2Mbps:
+ PlexDownloadVideoQualityConstraints(width: 1_280, height: 720, bitrate: 2_000)
+ case .sd1500Kbps:
+ PlexDownloadVideoQualityConstraints(width: 854, height: 480, bitrate: 1_500)
+ }
+ }
+}
+
+enum PlexDownloadSubtitlePreference: String, CaseIterable, Identifiable, Sendable {
+ case selectable
+ case burn
+ case none
+
+ var id: Self { self }
+
+ var label: String {
+ switch self {
+ case .selectable: "Selectable Track"
+ case .burn: "Burn Into Video"
+ case .none: "None"
+ }
+ }
+
+ fileprivate var decisionModes: (
+ subtitle: PlexDownloadSubtitleMode,
+ advanced: PlexDownloadAdvancedSubtitleMode?
+ ) {
+ switch self {
+ case .selectable:
+ (.embedded, .text)
+ case .burn:
+ (.burn, .burn)
+ case .none:
+ (.none, nil)
+ }
+ }
+}
+
+struct PlexDownloadPreferences: Equatable, Sendable {
+ static let `default` = Self(
+ videoQuality: .original,
+ musicQuality: .original,
+ subtitlePreference: .selectable
+ )
+
+ let videoQuality: PlexDownloadVideoQuality
+ let musicQuality: PlexMusicQuality
+ let subtitlePreference: PlexDownloadSubtitlePreference
+
+ func decisionParameters(
+ for item: PlexMediaItem,
+ source: PlexPlaybackSource,
+ sessionIdentifier: String,
+ nativeDirectPlaySupported: Bool = true,
+ clientProfileName: String? = nil,
+ clientProfileExtra: String? = nil
+ ) throws -> PlexDownloadDecisionParameters {
+ guard item.media.indices.contains(source.mediaIndex),
+ let sessionIdentifier = sessionIdentifier.nilIfBlank else {
+ throw PlexDownloadPreferencesError.invalidMediaSource
+ }
+
+ let media = item.media[source.mediaIndex]
+ let hasValidPartSelection = if source.partIndex == -1 {
+ media.parts.count > 1
+ } else {
+ media.parts.indices.contains(source.partIndex)
+ }
+ guard hasValidPartSelection else {
+ throw PlexDownloadPreferencesError.invalidMediaSource
+ }
+
+ let mediaPath = item.key?.nilIfBlank ?? "/library/metadata/\(item.ratingKey)"
+ if media.videoCodec?.nilIfBlank != nil {
+ return videoDecisionParameters(
+ media: media,
+ mediaPath: mediaPath,
+ source: source,
+ sessionIdentifier: sessionIdentifier,
+ nativeDirectPlaySupported: nativeDirectPlaySupported,
+ clientProfileName: clientProfileName,
+ clientProfileExtra: clientProfileExtra
+ )
+ }
+ if media.audioCodec?.nilIfBlank != nil {
+ return musicDecisionParameters(
+ media: media,
+ mediaPath: mediaPath,
+ source: source,
+ sessionIdentifier: sessionIdentifier,
+ nativeDirectPlaySupported: nativeDirectPlaySupported,
+ clientProfileName: clientProfileName,
+ clientProfileExtra: clientProfileExtra
+ )
+ }
+ throw PlexDownloadPreferencesError.unsupportedMedia
+ }
+
+ private func videoDecisionParameters(
+ media: PlexMediaVersion,
+ mediaPath: String,
+ source: PlexPlaybackSource,
+ sessionIdentifier: String,
+ nativeDirectPlaySupported: Bool,
+ clientProfileName: String?,
+ clientProfileExtra: String?
+ ) -> PlexDownloadDecisionParameters {
+ let limitsSource = videoQuality.limits(media: media)
+ let joinsMultipleParts = source.partIndex == -1
+ let requiresServerConversion = limitsSource || joinsMultipleParts
+ let constraints = videoQuality.constraints
+ let resolution = constraints.map { "\($0.width)x\($0.height)" }
+ ?? media.sourceResolution
+ let subtitleModes = subtitlePreference.decisionModes
+
+ return PlexDownloadDecisionParameters(
+ mediaPath: mediaPath,
+ mediaIndex: source.mediaIndex,
+ partIndex: source.partIndex,
+ deliveryProtocol: .http,
+ allowsDirectPlay: !requiresServerConversion && nativeDirectPlaySupported,
+ allowsDirectStream: !requiresServerConversion,
+ allowsDirectStreamAudio: !joinsMultipleParts,
+ subtitleMode: subtitleModes.subtitle,
+ advancedSubtitleMode: subtitleModes.advanced,
+ videoBitrate: constraints?.bitrate ?? media.positiveBitrate,
+ videoQuality: 99,
+ videoResolution: resolution,
+ sessionIdentifier: sessionIdentifier,
+ clientProfileName: clientProfileName,
+ clientProfileExtra: clientProfileExtra
+ )
+ }
+
+ private func musicDecisionParameters(
+ media: PlexMediaVersion,
+ mediaPath: String,
+ source: PlexPlaybackSource,
+ sessionIdentifier: String,
+ nativeDirectPlaySupported: Bool,
+ clientProfileName: String?,
+ clientProfileExtra: String?
+ ) -> PlexDownloadDecisionParameters {
+ let targetBitrate = musicQuality.bitrate ?? media.positiveBitrate
+ let limitsSource = musicQuality.limits(media: media)
+ let joinsMultipleParts = source.partIndex == -1
+ let requiresServerConversion = limitsSource || joinsMultipleParts
+ return PlexDownloadDecisionParameters(
+ mediaPath: mediaPath,
+ mediaIndex: source.mediaIndex,
+ partIndex: source.partIndex,
+ deliveryProtocol: .http,
+ allowsDirectPlay: !requiresServerConversion && nativeDirectPlaySupported,
+ allowsDirectStreamAudio: !requiresServerConversion,
+ musicBitrate: targetBitrate,
+ sessionIdentifier: sessionIdentifier,
+ clientProfileName: clientProfileName,
+ clientProfileExtra: clientProfileExtra
+ )
+ }
+}
+
+enum PlexDownloadPreferencesError: LocalizedError, Equatable {
+ case invalidMediaSource
+ case unsupportedMedia
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidMediaSource:
+ "Plex did not provide a valid media source for this download."
+ case .unsupportedMedia:
+ "This Plex item does not contain downloadable video or music."
+ }
+ }
+}
+
+private struct PlexDownloadVideoQualityConstraints: Equatable, Sendable {
+ let width: Int
+ let height: Int
+ let bitrate: Int
+}
+
+private extension PlexDownloadVideoQuality {
+ func limits(media: PlexMediaVersion) -> Bool {
+ guard let constraints else {
+ return false
+ }
+ guard let width = media.width, width > 0,
+ let height = media.height, height > 0,
+ let bitrate = media.positiveBitrate else {
+ return true
+ }
+ return width > constraints.width
+ || height > constraints.height
+ || bitrate > constraints.bitrate
+ }
+}
+
+private extension PlexMediaVersion {
+ var positiveBitrate: Int? {
+ guard let bitrate, bitrate > 0 else {
+ return nil
+ }
+ return bitrate
+ }
+
+ var sourceResolution: String? {
+ guard let width, width > 0, let height, height > 0 else {
+ return nil
+ }
+ return "\(width)x\(height)"
+ }
+}
diff --git a/PlexBar/Models/PlexDownloadQueue.swift b/PlexBar/Models/PlexDownloadQueue.swift
new file mode 100644
index 0000000..3a2c66c
--- /dev/null
+++ b/PlexBar/Models/PlexDownloadQueue.swift
@@ -0,0 +1,289 @@
+import PlexModels
+import Foundation
+
+enum PlexDownloadQueueStatus: String, Decodable, Equatable, Sendable {
+ case deciding
+ case waiting
+ case processing
+ case done
+ case error
+}
+
+struct PlexDownloadQueue: Decodable, Equatable, Sendable {
+ let id: Int
+ let status: PlexDownloadQueueStatus
+ let itemCount: Int
+}
+
+enum PlexDownloadQueueItemStatus: String, Decodable, Equatable, Sendable {
+ case deciding
+ case waiting
+ case processing
+ case available
+ case error
+ case expired
+}
+
+struct PlexDownloadDecisionResult: Decodable, Equatable, Sendable {
+ let mdeDecisionCode: Int?
+ let mdeDecisionText: String?
+ let availableBandwidth: Int?
+ let generalDecisionCode: Int?
+ let generalDecisionText: String?
+ let directPlayDecisionCode: Int?
+ let directPlayDecisionText: String?
+ let transcodeDecisionCode: Int?
+ let transcodeDecisionText: String?
+}
+
+struct PlexDownloadTranscodeSession: Decodable, Equatable, Sendable {
+ let key: String?
+ let throttled: Bool?
+ let complete: Bool?
+ let progress: Double?
+ let size: Int64?
+ let speed: Double?
+ let error: Bool?
+ let duration: Int64?
+ let context: String?
+ let sourceVideoCodec: String?
+ let sourceAudioCodec: String?
+ let `protocol`: String?
+ let transcodeHwRequested: Bool?
+ let transcodeHwFullPipeline: Bool?
+}
+
+struct PlexDownloadQueueItem: Decodable, Equatable, Identifiable, Sendable {
+ let id: Int
+ let queueID: Int
+ let key: String
+ let status: PlexDownloadQueueItemStatus
+ let error: String?
+ let decisionResult: PlexDownloadDecisionResult?
+ let transcodeSession: PlexDownloadTranscodeSession?
+
+ enum CodingKeys: String, CodingKey {
+ case id
+ case queueID = "queueId"
+ case key
+ case status
+ case error
+ case decisionResult = "DecisionResult"
+ case transcodeSession = "TranscodeSession"
+ }
+
+ var failureDescription: String? {
+ let decisionMessages = [
+ decisionResult?.generalDecisionText,
+ decisionResult?.mdeDecisionText,
+ decisionResult?.directPlayDecisionText,
+ decisionResult?.transcodeDecisionText,
+ ]
+ .compactMap { $0?.nilIfBlank }
+ .reduce(into: [String]()) { messages, message in
+ if !messages.contains(message) {
+ messages.append(message)
+ }
+ }
+
+ if !decisionMessages.isEmpty {
+ return decisionMessages.joined(separator: " ")
+ }
+ return error?.nilIfBlank
+ }
+}
+
+struct PlexAddedDownloadQueueItem: Decodable, Equatable, Sendable {
+ let key: String
+ let id: Int
+}
+
+enum PlexDownloadProtocol: String, Codable, Equatable, Sendable {
+ case http
+ case hls
+ case dash
+}
+
+enum PlexDownloadSubtitleMode: String, Codable, Equatable, Sendable {
+ case automatic = "auto"
+ case burn
+ case none
+ case sidecar
+ case embedded
+ case segmented
+ case unknown
+}
+
+enum PlexDownloadAdvancedSubtitleMode: String, Codable, Equatable, Sendable {
+ case burn
+ case text
+ case unknown
+}
+
+struct PlexDownloadDecisionParameters: Codable, Equatable, Sendable {
+ let mediaPath: String?
+ let mediaIndex: Int?
+ let partIndex: Int?
+ let deliveryProtocol: PlexDownloadProtocol?
+ let allowsDirectPlay: Bool?
+ let allowsDirectStream: Bool?
+ let allowsDirectStreamAudio: Bool?
+ let subtitleMode: PlexDownloadSubtitleMode?
+ let advancedSubtitleMode: PlexDownloadAdvancedSubtitleMode?
+ let videoBitrate: Int?
+ let videoQuality: Int?
+ let videoResolution: String?
+ let musicBitrate: Int?
+ let sessionIdentifier: String?
+ let clientProfileName: String?
+ let clientProfileExtra: String?
+
+ init(
+ mediaPath: String? = nil,
+ mediaIndex: Int? = nil,
+ partIndex: Int? = nil,
+ deliveryProtocol: PlexDownloadProtocol? = nil,
+ allowsDirectPlay: Bool? = nil,
+ allowsDirectStream: Bool? = nil,
+ allowsDirectStreamAudio: Bool? = nil,
+ subtitleMode: PlexDownloadSubtitleMode? = nil,
+ advancedSubtitleMode: PlexDownloadAdvancedSubtitleMode? = nil,
+ videoBitrate: Int? = nil,
+ videoQuality: Int? = nil,
+ videoResolution: String? = nil,
+ musicBitrate: Int? = nil,
+ sessionIdentifier: String? = nil,
+ clientProfileName: String? = nil,
+ clientProfileExtra: String? = nil
+ ) {
+ self.mediaPath = mediaPath
+ self.mediaIndex = mediaIndex
+ self.partIndex = partIndex
+ self.deliveryProtocol = deliveryProtocol
+ self.allowsDirectPlay = allowsDirectPlay
+ self.allowsDirectStream = allowsDirectStream
+ self.allowsDirectStreamAudio = allowsDirectStreamAudio
+ self.subtitleMode = subtitleMode
+ self.advancedSubtitleMode = advancedSubtitleMode
+ self.videoBitrate = videoBitrate
+ self.videoQuality = videoQuality
+ self.videoResolution = videoResolution
+ self.musicBitrate = musicBitrate
+ self.sessionIdentifier = sessionIdentifier
+ self.clientProfileName = clientProfileName
+ self.clientProfileExtra = clientProfileExtra
+ }
+}
+
+struct PlexDownloadQueueDecision: Decodable, Sendable {
+ let allowSync: Bool?
+ let generalDecisionCode: Int?
+ let generalDecisionText: String?
+ let directPlayDecisionCode: Int?
+ let directPlayDecisionText: String?
+ let transcodeDecisionCode: Int?
+ let transcodeDecisionText: String?
+ let resourceSession: String?
+ let metadata: [PlexMediaItem]
+
+ enum CodingKeys: String, CodingKey {
+ case allowSync
+ case generalDecisionCode
+ case generalDecisionText
+ case directPlayDecisionCode
+ case directPlayDecisionText
+ case transcodeDecisionCode
+ case transcodeDecisionText
+ case resourceSession
+ case metadata = "Metadata"
+ }
+
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ allowSync = values.decodePlexBoolIfPresent(forKey: .allowSync)
+ generalDecisionCode = values.decodePlexIntIfPresent(forKey: .generalDecisionCode)
+ generalDecisionText = try values.decodeIfPresent(String.self, forKey: .generalDecisionText)
+ directPlayDecisionCode = values.decodePlexIntIfPresent(forKey: .directPlayDecisionCode)
+ directPlayDecisionText = try values.decodeIfPresent(String.self, forKey: .directPlayDecisionText)
+ transcodeDecisionCode = values.decodePlexIntIfPresent(forKey: .transcodeDecisionCode)
+ transcodeDecisionText = try values.decodeIfPresent(String.self, forKey: .transcodeDecisionText)
+ resourceSession = try values.decodeIfPresent(String.self, forKey: .resourceSession)
+ metadata = try values.decodeIfPresent([PlexMediaItem].self, forKey: .metadata) ?? []
+ }
+}
+
+struct PlexDownloadQueueEnvelope: Decodable {
+ let mediaContainer: Container
+
+ struct Container: Decodable {
+ let queues: [PlexDownloadQueue]
+
+ enum CodingKeys: String, CodingKey {
+ case queues = "DownloadQueue"
+ }
+
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ queues = try values.decodeIfPresent([PlexDownloadQueue].self, forKey: .queues) ?? []
+ }
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case mediaContainer = "MediaContainer"
+ }
+}
+
+struct PlexDownloadQueueItemsEnvelope: Decodable {
+ let mediaContainer: Container
+
+ struct Container: Decodable {
+ let items: [PlexDownloadQueueItem]
+
+ enum CodingKeys: String, CodingKey {
+ case items = "DownloadQueueItem"
+ }
+
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ items = try values.decodeIfPresent([PlexDownloadQueueItem].self, forKey: .items) ?? []
+ }
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case mediaContainer = "MediaContainer"
+ }
+}
+
+struct PlexAddedDownloadQueueItemsEnvelope: Decodable {
+ let mediaContainer: Container
+
+ struct Container: Decodable {
+ let items: [PlexAddedDownloadQueueItem]
+
+ enum CodingKeys: String, CodingKey {
+ case items = "AddedQueueItems"
+ }
+
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ items = try values.decodeIfPresent([PlexAddedDownloadQueueItem].self, forKey: .items) ?? []
+ }
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case mediaContainer = "MediaContainer"
+ }
+}
+
+struct PlexDownloadQueueDecisionEnvelope: Decodable {
+ let mediaContainer: PlexDownloadQueueDecision
+
+ enum CodingKeys: String, CodingKey {
+ case mediaContainer = "MediaContainer"
+ }
+}
+
+struct PlexDownloadQueueDecisionDocument: Sendable {
+ let decision: PlexDownloadQueueDecision
+ let data: Data
+}
diff --git a/PlexBar/Models/PlexDownloadTransfer.swift b/PlexBar/Models/PlexDownloadTransfer.swift
new file mode 100644
index 0000000..c3013cd
--- /dev/null
+++ b/PlexBar/Models/PlexDownloadTransfer.swift
@@ -0,0 +1,144 @@
+import Foundation
+
+enum PlexDownloadTransferState: String, Codable, Equatable, Sendable {
+ case scheduled
+ case transferring
+ case paused
+ case downloaded
+ case publishing
+ case failed
+}
+
+enum PlexDownloadTransferFailure: String, Codable, Equatable, Sendable {
+ case invalidRequest
+ case missingTask
+ case invalidTaskIdentity
+ case serverResponse
+ case transfer
+ case handoff
+ case publication
+}
+
+struct PlexDownloadTransferRecord: Codable, Equatable, Identifiable, Sendable {
+ let id: UUID
+ let packageIdentity: PlexDownloadPackageIdentity
+ let title: String
+ let mediaType: String?
+ let decisionData: Data
+ let mediaFileExtension: String?
+ let contentType: String?
+ let taskIdentifier: Int
+ let createdAt: Date
+ var state: PlexDownloadTransferState
+ var failure: PlexDownloadTransferFailure?
+
+ init(
+ id: UUID = UUID(),
+ packageIdentity: PlexDownloadPackageIdentity,
+ title: String,
+ mediaType: String?,
+ decisionData: Data,
+ mediaFileExtension: String?,
+ contentType: String?,
+ taskIdentifier: Int,
+ createdAt: Date = Date(),
+ state: PlexDownloadTransferState = .scheduled,
+ failure: PlexDownloadTransferFailure? = nil
+ ) {
+ self.id = id
+ self.packageIdentity = packageIdentity
+ self.title = title
+ self.mediaType = mediaType
+ self.decisionData = decisionData
+ self.mediaFileExtension = mediaFileExtension
+ self.contentType = contentType
+ self.taskIdentifier = taskIdentifier
+ self.createdAt = createdAt
+ self.state = state
+ self.failure = failure
+ }
+}
+
+struct PlexDownloadTransferRequest: Sendable {
+ let packageIdentity: PlexDownloadPackageIdentity
+ let title: String
+ let mediaType: String?
+ let decisionData: Data
+ let mediaFileExtension: String?
+ let contentType: String?
+ let request: URLRequest
+}
+
+struct PlexDownloadTransferTaskSnapshot: Equatable, Sendable {
+ enum State: Int, Equatable, Sendable {
+ case running
+ case suspended
+ case canceling
+ case completed
+ }
+
+ let taskIdentifier: Int
+ let taskDescription: String?
+ let state: State
+ let countOfBytesReceived: Int64
+ let countOfBytesExpectedToReceive: Int64
+}
+
+struct PlexDownloadTransferProgress: Equatable, Sendable {
+ let transferID: UUID
+ let bytesReceived: Int64
+ let bytesExpected: Int64?
+
+ var fractionCompleted: Double? {
+ guard let bytesExpected, bytesExpected > 0 else {
+ return nil
+ }
+ return min(max(Double(bytesReceived) / Double(bytesExpected), 0), 1)
+ }
+}
+
+enum PlexDownloadTransferEvent: Sendable {
+ case progress(
+ taskIdentifier: Int,
+ taskDescription: String?,
+ bytesReceived: Int64,
+ bytesExpected: Int64
+ )
+ case handoffCompleted(
+ taskIdentifier: Int,
+ taskDescription: String?,
+ result: Result
+ )
+ case taskCompleted(
+ taskIdentifier: Int,
+ taskDescription: String?,
+ errorCode: Int?
+ )
+ case waitingForConnectivity(
+ taskIdentifier: Int,
+ taskDescription: String?
+ )
+}
+
+enum PlexDownloadTransferError: LocalizedError {
+ case invalidRequest
+ case authorizationExpired
+ case duplicateTransfer
+ case taskCreationFailed
+ case registryUnavailable
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidRequest:
+ "PlexBar could not create a valid background download request."
+ case .authorizationExpired:
+ "The Plex download authorization expired before the transfer started."
+ case .duplicateTransfer:
+ "This Plex item already has an active download transfer."
+ case .taskCreationFailed:
+ "Foundation could not create the background download task."
+ case .registryUnavailable:
+ "PlexBar could not persist the download before starting it."
+ }
+ }
+}
diff --git a/PlexBar/Models/PlexDownloadWorkflow.swift b/PlexBar/Models/PlexDownloadWorkflow.swift
new file mode 100644
index 0000000..e588a5e
--- /dev/null
+++ b/PlexBar/Models/PlexDownloadWorkflow.swift
@@ -0,0 +1,93 @@
+import PlexModels
+import Foundation
+
+enum PlexDownloadJobState: String, Codable, Equatable, Sendable {
+ case waitingForServer
+ case transferring
+ case paused
+ case failed
+}
+
+struct PlexDownloadJob: Codable, Equatable, Identifiable, Sendable {
+ let id: UUID
+ let accountID: Int
+ let packageIdentity: PlexDownloadPackageIdentity
+ let libraryID: String
+ let title: String
+ let mediaType: String?
+ let source: PlexPlaybackSource
+ let decisionParameters: PlexDownloadDecisionParameters
+ let createdAt: Date
+ var updatedAt: Date
+ var state: PlexDownloadJobState
+ var serverPreparationProgress: Double?
+ var transferID: UUID?
+ var errorMessage: String?
+}
+
+struct PlexOfflineMedia: Identifiable, Equatable, Sendable {
+ let package: PlexDownloadPackage
+ let item: PlexMediaItem
+
+ var id: UUID { package.id }
+}
+
+struct PlexOfflinePlaybackRecord: Codable, Equatable, Identifiable, Sendable {
+ let packageID: UUID
+ let accountID: Int?
+ let serverIdentifier: String
+ let ratingKey: String
+ var baselineViewOffset: Int?
+ var baselineViewCount: Int?
+ var position: Int
+ var duration: Int
+ var state: PlexTimelineState
+ var updatedAt: Date
+ var needsSync: Bool
+
+ var id: UUID { packageID }
+}
+
+enum PlexDownloadWorkflowError: LocalizedError, Equatable {
+ case unsupportedItem
+ case missingLibrary
+ case alreadyDownloaded
+ case alreadyInProgress
+ case invalidQueueResponse
+ case serverPreparationFailed(String)
+ case unavailableOfflineMedia
+ case serverChanged
+ case staleOfflineProgress
+ case unsupportedAutomaticDownload
+ case automaticDownloadAlreadyExists
+ case invalidAutomaticDownloadHierarchy
+
+ var errorDescription: String? {
+ switch self {
+ case .unsupportedItem:
+ "This item does not contain media that Plex can download."
+ case .missingLibrary:
+ "Plex did not identify the library that owns this item."
+ case .alreadyDownloaded:
+ "This item is already downloaded."
+ case .alreadyInProgress:
+ "This item is already being downloaded."
+ case .invalidQueueResponse:
+ "Plex did not return the download queue item it created."
+ case .serverPreparationFailed(let message):
+ message
+ case .unavailableOfflineMedia:
+ "The downloaded media package is incomplete or no longer available."
+ case .serverChanged:
+ "Select the Plex server that owns this download to continue."
+ case .staleOfflineProgress:
+ "Plex has newer watch progress, so this offline progress was not uploaded."
+ case .unsupportedAutomaticDownload:
+ "Automatic downloads are available for shows and seasons."
+ case .automaticDownloadAlreadyExists:
+ "An automatic download already exists for this title."
+ case .invalidAutomaticDownloadHierarchy:
+ "Plex returned an invalid season or episode hierarchy for this title."
+ }
+ }
+}
diff --git a/PlexBar/Models/PlexEpisodeContinuity.swift b/PlexBar/Models/PlexEpisodeContinuity.swift
new file mode 100644
index 0000000..2e62b76
--- /dev/null
+++ b/PlexBar/Models/PlexEpisodeContinuity.swift
@@ -0,0 +1,50 @@
+import PlexModels
+import Foundation
+
+enum PlexEpisodeContinuity {
+ static func nextEpisode(
+ after current: PlexMediaItem,
+ in episodes: [PlexMediaItem]
+ ) -> PlexMediaItem? {
+ nextItem(
+ afterRatingKey: current.ratingKey,
+ index: current.index,
+ in: episodes.filter { $0.type?.lowercased() == "episode" }
+ )
+ }
+
+ static func nextSeason(
+ afterRatingKey ratingKey: String,
+ index: Int?,
+ in seasons: [PlexMediaItem]
+ ) -> PlexMediaItem? {
+ nextItem(
+ afterRatingKey: ratingKey,
+ index: index,
+ in: seasons.filter { $0.type?.lowercased() == "season" }
+ )
+ }
+
+ static func ordered(_ items: [PlexMediaItem]) -> [PlexMediaItem] {
+ items.sorted { lhs, rhs in
+ if lhs.index != rhs.index {
+ return (lhs.index ?? .max) < (rhs.index ?? .max)
+ }
+ return lhs.ratingKey.localizedStandardCompare(rhs.ratingKey) == .orderedAscending
+ }
+ }
+
+ private static func nextItem(
+ afterRatingKey ratingKey: String,
+ index: Int?,
+ in values: [PlexMediaItem]
+ ) -> PlexMediaItem? {
+ let orderedValues = ordered(values)
+ if let currentIndex = orderedValues.firstIndex(where: { $0.ratingKey == ratingKey }),
+ orderedValues.indices.contains(currentIndex + 1) {
+ return orderedValues[currentIndex + 1]
+ }
+ guard let index else { return nil }
+ return orderedValues.first { ($0.index ?? .max) > index }
+ }
+}
diff --git a/Sources/PlexBar/Models/PlexHistory.swift b/PlexBar/Models/PlexHistory.swift
similarity index 77%
rename from Sources/PlexBar/Models/PlexHistory.swift
rename to PlexBar/Models/PlexHistory.swift
index 7398524..bc370e8 100644
--- a/Sources/PlexBar/Models/PlexHistory.swift
+++ b/PlexBar/Models/PlexHistory.swift
@@ -1,3 +1,4 @@
+import PlexModels
import Foundation
struct PlexHistorySeriesIdentity: Equatable {
@@ -89,7 +90,7 @@ struct PlexHistoryItem: Decodable, Identifiable {
historyKey = try container.decodeIfPresent(String.self, forKey: .historyKey)
key = try container.decodeIfPresent(String.self, forKey: .key)
ratingKey = try container.decodeIfPresent(String.self, forKey: .ratingKey)
- title = try container.decode(String.self, forKey: .title)
+ title = try container.decodeIfPresent(String.self, forKey: .title) ?? "Untitled"
type = try container.decodeIfPresent(String.self, forKey: .type)
thumb = try container.decodeIfPresent(String.self, forKey: .thumb)
parentThumb = try container.decodeIfPresent(String.self, forKey: .parentThumb)
@@ -99,7 +100,8 @@ struct PlexHistoryItem: Decodable, Identifiable {
parentTitle = try container.decodeIfPresent(String.self, forKey: .parentTitle)
parentIndex = try container.decodeIfPresent(Int.self, forKey: .parentIndex)
index = try container.decodeIfPresent(Int.self, forKey: .index)
- originallyAvailableAt = try container.decodeIfPresent(String.self, forKey: .originallyAvailableAt)
+ originallyAvailableAt = try container.decodeIfPresent(
+ String.self, forKey: .originallyAvailableAt)
accountID = try container.decodeIfPresent(Int.self, forKey: .accountID)
deviceID = try container.decodeIfPresent(Int.self, forKey: .deviceID)
@@ -107,7 +109,9 @@ struct PlexHistoryItem: Decodable, Identifiable {
viewedAt = Date(timeIntervalSince1970: viewedAtTimestamp)
} else if let viewedAtTimestamp = try container.decodeIfPresent(Int.self, forKey: .viewedAt) {
viewedAt = Date(timeIntervalSince1970: Double(viewedAtTimestamp))
- } else if let viewedAtTimestamp = try container.decodeIfPresent(String.self, forKey: .viewedAt).flatMap(Double.init) {
+ } else if let viewedAtTimestamp = try container.decodeIfPresent(String.self, forKey: .viewedAt)
+ .flatMap(Double.init)
+ {
viewedAt = Date(timeIntervalSince1970: viewedAtTimestamp)
} else {
viewedAt = nil
@@ -138,16 +142,31 @@ struct PlexHistoryItem: Decodable, Identifiable {
return ratingKey?.nilIfBlank
}
+ var mediaRoute: PlexMediaRoute? {
+ PlexMediaRoute(ratingKey: ratingKey)
+ }
+
var posterPath: String? {
preferredPosterCandidates
.compactMap { $0?.nilIfBlank }
.first
}
+ func posterPath(spoilerPolicy: PlexEpisodeSpoilerPolicy) -> String? {
+ guard type?.lowercased() == "episode",
+ spoilerPolicy == .allEpisodes else {
+ return posterPath
+ }
+
+ return [grandparentThumb, parentThumb]
+ .compactMap { $0?.nilIfBlank }
+ .first
+ }
+
var headline: String {
switch contentKind {
case .tv:
- grandparentTitle ?? title
+ grandparentTitle?.nilIfBlank ?? title
default:
title
}
@@ -156,8 +175,7 @@ struct PlexHistoryItem: Decodable, Identifiable {
var detailLine: String? {
switch contentKind {
case .tv:
- let pieces = [episodeCode, title.nilIfBlank].compactMap { $0 }
- return pieces.isEmpty ? nil : pieces.joined(separator: " • ")
+ return PlexEpisodeText.subtitle(season: parentIndex, episode: index, title: title).nilIfBlank
case .track:
let pieces = [parentTitle?.nilIfBlank, title.nilIfBlank].compactMap { $0 }
return pieces.isEmpty ? nil : pieces.joined(separator: " • ")
@@ -202,7 +220,8 @@ struct PlexHistoryItem: Decodable, Identifiable {
switch contentKind {
case .tv:
guard let episodeID = ratingKey?.nilIfBlank,
- let seriesIdentity = seriesByEpisodeID[episodeID] else {
+ let seriesIdentity = seriesByEpisodeID[episodeID]
+ else {
return nil
}
@@ -232,6 +251,20 @@ struct PlexHistoryItem: Decodable, Identifiable {
}
}
+ func chartDestinationRatingKey(
+ seriesByEpisodeID: [String: PlexHistorySeriesIdentity]
+ ) -> String? {
+ switch contentKind {
+ case .tv:
+ guard let episodeID = ratingKey?.nilIfBlank else {
+ return nil
+ }
+ return seriesByEpisodeID[episodeID]?.id.nilIfBlank
+ default:
+ return ratingKey?.nilIfBlank
+ }
+ }
+
func watcherName(using accountsByID: [Int: PlexAccount]) -> String? {
guard let accountID else {
return nil
@@ -248,21 +281,24 @@ struct PlexHistoryItem: Decodable, Identifiable {
return accountsByID[accountID]
}
- private var releaseYear: String? {
- guard let component = originallyAvailableAt?
- .split(separator: "-")
- .first else {
+ func playbackDevice(using devicesByID: [Int: PlexHistoryDevice]) -> PlexHistoryDevice? {
+ guard let deviceID else {
return nil
}
- return String(component).nilIfBlank
+ return devicesByID[deviceID]
}
- private var episodeCode: String? {
- let season = parentIndex.map { "S\($0)" }
- let episode = index.map { String(format: "E%02d", $0) }
- let code = [season, episode].compactMap { $0 }.joined()
- return code.isEmpty ? nil : code
+ private var releaseYear: String? {
+ guard
+ let component = originallyAvailableAt?
+ .split(separator: "-")
+ .first
+ else {
+ return nil
+ }
+
+ return String(component).nilIfBlank
}
private var preferredPosterCandidates: [String?] {
@@ -277,17 +313,46 @@ struct PlexHistoryItem: Decodable, Identifiable {
}
}
+struct PlexMediaHistoryRowPresentation: Equatable {
+ let title: String
+ let subtitle: String?
+ let viewerLine: String
+ let deviceLine: String?
+
+ init(
+ item: PlexHistoryItem,
+ account: PlexAccount?,
+ device: PlexHistoryDevice?
+ ) {
+ title = item.headline
+ subtitle = item.detailLine?.nilIfBlank
+ viewerLine = account?.name.nilIfBlank.map { "Watched by \($0)" } ?? "Viewer unavailable"
+ deviceLine = device?.displayLine
+ }
+
+ var contextLine: String {
+ [viewerLine, deviceLine]
+ .compactMap { $0?.nilIfBlank }
+ .joined(separator: " • ")
+ }
+}
+
struct PlexTopChartEntry: Identifiable, Equatable {
let id: String
let title: String
let subtitle: String
let playCount: Int
+ let destinationRatingKey: String?
let posterPath: String?
let symbolName: String
let watcherSummary: String?
let watcherAccountIDs: [Int]
let coverageLabel: String?
let viewerCountLabel: String?
+
+ var mediaRoute: PlexMediaRoute? {
+ PlexMediaRoute(ratingKey: destinationRatingKey)
+ }
}
struct PlexUserActivityEntry: Identifiable, Equatable {
@@ -373,45 +438,50 @@ enum PlexHistoryAnalytics {
limit: Int,
filter: PlexHistoryContentFilter = .all
) -> [PlexTopChartEntry] {
- Dictionary(grouping: items.lazy.filter(filter.includes).compactMap { item in
- item.chartGroupKey(seriesByEpisodeID: seriesByEpisodeID).map { ($0, item) }
- }, by: \.0)
- .values
- .map { $0.map(\.1) }
- .sorted { lhs, rhs in
- if lhs.count != rhs.count {
- return lhs.count > rhs.count
- }
-
- let lhsTitle = lhs.first?.chartDisplayTitle(seriesByEpisodeID: seriesByEpisodeID) ?? ""
- let rhsTitle = rhs.first?.chartDisplayTitle(seriesByEpisodeID: seriesByEpisodeID) ?? ""
+ Dictionary(
+ grouping: items.lazy.filter(filter.includes).compactMap { item in
+ item.chartGroupKey(seriesByEpisodeID: seriesByEpisodeID).map { ($0, item) }
+ }, by: \.0
+ )
+ .values
+ .map { $0.map(\.1) }
+ .sorted { lhs, rhs in
+ if lhs.count != rhs.count {
+ return lhs.count > rhs.count
+ }
- if lhsTitle.localizedCaseInsensitiveCompare(rhsTitle) != .orderedSame {
- return lhsTitle.localizedCaseInsensitiveCompare(rhsTitle) == .orderedAscending
- }
+ let lhsTitle = lhs.first?.chartDisplayTitle(seriesByEpisodeID: seriesByEpisodeID) ?? ""
+ let rhsTitle = rhs.first?.chartDisplayTitle(seriesByEpisodeID: seriesByEpisodeID) ?? ""
- let lhsID = lhs.first?.chartGroupKey(seriesByEpisodeID: seriesByEpisodeID) ?? ""
- let rhsID = rhs.first?.chartGroupKey(seriesByEpisodeID: seriesByEpisodeID) ?? ""
- return lhsID < rhsID
- }
- .prefix(limit)
- .map { group in
- let representative = group[0]
- let playCount = group.count
-
- return PlexTopChartEntry(
- id: representative.chartGroupKey(seriesByEpisodeID: seriesByEpisodeID) ?? representative.id,
- title: representative.chartDisplayTitle(seriesByEpisodeID: seriesByEpisodeID),
- subtitle: chartSubtitle(for: group, representative: representative),
- playCount: playCount,
- posterPath: representative.chartPosterPath(seriesByEpisodeID: seriesByEpisodeID),
- symbolName: representative.contentKind.symbolName,
- watcherSummary: watcherSummary(for: group, accountsByID: accountsByID),
- watcherAccountIDs: watcherAccountIDs(for: group, accountsByID: accountsByID),
- coverageLabel: coverageLabel(for: group, representative: representative),
- viewerCountLabel: viewerCountLabel(for: group)
- )
+ if lhsTitle.localizedCaseInsensitiveCompare(rhsTitle) != .orderedSame {
+ return lhsTitle.localizedCaseInsensitiveCompare(rhsTitle) == .orderedAscending
}
+
+ let lhsID = lhs.first?.chartGroupKey(seriesByEpisodeID: seriesByEpisodeID) ?? ""
+ let rhsID = rhs.first?.chartGroupKey(seriesByEpisodeID: seriesByEpisodeID) ?? ""
+ return lhsID < rhsID
+ }
+ .prefix(limit)
+ .map { group in
+ let representative = group[0]
+ let playCount = group.count
+
+ return PlexTopChartEntry(
+ id: representative.chartGroupKey(seriesByEpisodeID: seriesByEpisodeID) ?? representative.id,
+ title: representative.chartDisplayTitle(seriesByEpisodeID: seriesByEpisodeID),
+ subtitle: chartSubtitle(for: group, representative: representative),
+ playCount: playCount,
+ destinationRatingKey: representative.chartDestinationRatingKey(
+ seriesByEpisodeID: seriesByEpisodeID
+ ),
+ posterPath: representative.chartPosterPath(seriesByEpisodeID: seriesByEpisodeID),
+ symbolName: representative.contentKind.symbolName,
+ watcherSummary: watcherSummary(for: group, accountsByID: accountsByID),
+ watcherAccountIDs: watcherAccountIDs(for: group, accountsByID: accountsByID),
+ coverageLabel: coverageLabel(for: group, representative: representative),
+ viewerCountLabel: viewerCountLabel(for: group)
+ )
+ }
}
static func topTypeEntries(
@@ -426,6 +496,7 @@ enum PlexHistoryAnalytics {
title: kind.displayName,
subtitle: group.count == 1 ? "1 recent play" : "\(group.count) recent plays",
playCount: group.count,
+ destinationRatingKey: nil,
posterPath: nil,
symbolName: kind.symbolName,
watcherSummary: watcherSummary(for: group, accountsByID: accountsByID),
@@ -486,7 +557,8 @@ enum PlexHistoryAnalytics {
case .tv:
let uniqueEpisodeCount = Set(
items.map { item in
- item.ratingKey ?? item.key ?? "\(item.parentIndex ?? -1)-\(item.index ?? -1)-\(item.title)"
+ item.ratingKey ?? item.key
+ ?? "\(item.parentIndex ?? -1)-\(item.index ?? -1)-\(item.title)"
}
).count
@@ -509,7 +581,8 @@ enum PlexHistoryAnalytics {
case .tv:
let uniqueEpisodeCount = Set(
items.map { item in
- item.ratingKey ?? item.key ?? "\(item.parentIndex ?? -1)-\(item.index ?? -1)-\(item.title)"
+ item.ratingKey ?? item.key
+ ?? "\(item.parentIndex ?? -1)-\(item.index ?? -1)-\(item.title)"
}
).count
let label = uniqueEpisodeCount == 1 ? "episode" : "episodes"
@@ -578,9 +651,11 @@ enum PlexHistoryAnalytics {
from items: [PlexHistoryItem],
accountsByID: [Int: PlexAccount]
) -> [PlexUserActivityEntry] {
- Dictionary(grouping: items.compactMap { item in
- item.accountID.map { ($0, item) }
- }, by: \.0)
+ Dictionary(
+ grouping: items.compactMap { item in
+ item.accountID.map { ($0, item) }
+ }, by: \.0
+ )
.map { accountID, groupedItems in
let items = groupedItems.map(\.1)
let account = accountsByID[accountID]
diff --git a/PlexBar/Models/PlexHub.swift b/PlexBar/Models/PlexHub.swift
new file mode 100644
index 0000000..7565968
--- /dev/null
+++ b/PlexBar/Models/PlexHub.swift
@@ -0,0 +1,113 @@
+import PlexModels
+import Foundation
+
+struct PlexHubEnvelope: Decodable, Sendable {
+ let mediaContainer: PlexHubContainer
+
+ enum CodingKeys: String, CodingKey {
+ case mediaContainer = "MediaContainer"
+ }
+}
+
+struct PlexHubContainer: Decodable, Sendable {
+ let hubs: [PlexHub]
+
+ enum CodingKeys: String, CodingKey {
+ case hubs = "Hub"
+ }
+
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ hubs = try values.decodeIfPresent([PlexHub].self, forKey: .hubs) ?? []
+ }
+}
+
+struct PlexHub: Decodable, Equatable, Identifiable, Sendable {
+ let hubIdentifier: String
+ let hubKey: String?
+ let key: String?
+ let title: String
+ let type: String?
+ let style: String?
+ let size: Int?
+ let totalSize: Int?
+ let more: Bool
+ let promoted: Bool
+ var metadata: [PlexMediaItem]
+
+ var id: String {
+ hubIdentifier
+ }
+
+ var prefersPosterArtwork: Bool {
+ switch hubIdentifier.lowercased() {
+ case "continuewatching", "home.continue", "home.ondeck":
+ true
+ default:
+ false
+ }
+ }
+
+ var isContinueWatching: Bool {
+ hubIdentifier.caseInsensitiveCompare("continueWatching") == .orderedSame
+ || hubIdentifier.caseInsensitiveCompare("home.continue") == .orderedSame
+ }
+
+ /// The dedicated feed owns continuation eligibility and item order. Never merge
+ /// the legacy promoted rows into it, even when the unified feed is empty.
+ static func homeHubs(promoted: [PlexHub], continueWatching: [PlexHub]) throws -> [PlexHub] {
+ guard continueWatching.count <= 1,
+ continueWatching.allSatisfy({
+ $0.hubIdentifier.caseInsensitiveCompare("continueWatching") == .orderedSame
+ }) else {
+ throw PlexAPIError.invalidResponse
+ }
+ return continueWatching.filter { !$0.metadata.isEmpty }
+ + promoted.filter {
+ !$0.metadata.isEmpty && !$0.isContinueWatching
+ && $0.hubIdentifier.caseInsensitiveCompare("home.ondeck") != .orderedSame
+ }
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case hubIdentifier
+ case hubKey
+ case key
+ case title
+ case type
+ case style
+ case size
+ case totalSize
+ case more
+ case promoted
+ case metadata = "Metadata"
+ case directories = "Directory"
+ }
+
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ hubIdentifier = try values.decode(String.self, forKey: .hubIdentifier)
+ hubKey = try values.decodeIfPresent(String.self, forKey: .hubKey)
+ key = try values.decodeIfPresent(String.self, forKey: .key)
+ title = try values.decode(String.self, forKey: .title)
+ type = try values.decodeIfPresent(String.self, forKey: .type)
+ style = try values.decodeIfPresent(String.self, forKey: .style)
+ size = values.decodePlexIntIfPresent(forKey: .size)
+ totalSize = values.decodePlexIntIfPresent(forKey: .totalSize)
+ more = values.decodePlexBoolIfPresent(forKey: .more) ?? false
+ promoted = values.decodePlexBoolIfPresent(forKey: .promoted) ?? false
+ let metadataItems = try values.decodeIfPresent([PlexMediaItem].self, forKey: .metadata) ?? []
+ let directoryItems = try values
+ .decodeIfPresent([PlexHubDirectoryMediaItem].self, forKey: .directories)?
+ .compactMap(\.mediaItem) ?? []
+ metadata = metadataItems + directoryItems
+ }
+}
+
+private struct PlexHubDirectoryMediaItem: Decodable {
+ let mediaItem: PlexMediaItem?
+
+ init(from decoder: Decoder) throws {
+ mediaItem = try? PlexMediaItem(from: decoder)
+ }
+}
diff --git a/PlexBar/Models/PlexLibraryBrowse.swift b/PlexBar/Models/PlexLibraryBrowse.swift
new file mode 100644
index 0000000..c930692
--- /dev/null
+++ b/PlexBar/Models/PlexLibraryBrowse.swift
@@ -0,0 +1,474 @@
+import PlexModels
+import Foundation
+
+enum PlexLibrarySortDirection: String, Equatable, Hashable, Sendable {
+ case ascending = "asc"
+ case descending = "desc"
+
+ var title: String {
+ switch self {
+ case .ascending:
+ "Ascending"
+ case .descending:
+ "Descending"
+ }
+ }
+}
+
+enum PlexLibraryFilterValueType: Equatable, Hashable, Sendable {
+ case boolean
+ case integer
+ case string
+ case unknown(String)
+
+ init(rawValue: String) {
+ switch rawValue.lowercased() {
+ case "boolean":
+ self = .boolean
+ case "integer":
+ self = .integer
+ case "string":
+ self = .string
+ default:
+ self = .unknown(rawValue)
+ }
+ }
+}
+
+struct PlexLibraryFilterDefinition: Decodable, Equatable, Hashable, Identifiable, Sendable {
+ let id: String
+ let title: String
+ let valueType: PlexLibraryFilterValueType
+ let valuesPath: String?
+
+ private enum CodingKeys: String, CodingKey {
+ case id = "filter"
+ case title
+ case filterType
+ case valuesPath = "key"
+ }
+
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ id = try values.decode(String.self, forKey: .id)
+ title = try values.decode(String.self, forKey: .title)
+ valueType = PlexLibraryFilterValueType(
+ rawValue: try values.decode(String.self, forKey: .filterType)
+ )
+ valuesPath = try values.decodeIfPresent(String.self, forKey: .valuesPath)?.nilIfBlank
+ }
+}
+
+struct PlexLibraryFilterValue: Equatable, Hashable, Identifiable, Sendable {
+ let filterID: String
+ let queryName: String
+ let queryValue: String
+ let title: String
+
+ var id: String {
+ [filterID, queryName, queryValue].joined(separator: "|")
+ }
+}
+
+struct PlexLibrarySortDefinition: Decodable, Equatable, Hashable, Identifiable, Sendable {
+ let id: String
+ let title: String
+ let descendingKey: String?
+ let defaultDirection: PlexLibrarySortDirection
+ let defaultSelectionDirection: PlexLibrarySortDirection?
+
+ private enum CodingKeys: String, CodingKey {
+ case id = "key"
+ case title
+ case descendingKey = "descKey"
+ case defaultDirection
+ case defaultSelectionDirection = "default"
+ }
+
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ id = try values.decode(String.self, forKey: .id)
+ title = try values.decode(String.self, forKey: .title)
+ descendingKey = try values.decodeIfPresent(String.self, forKey: .descendingKey)?.nilIfBlank
+ defaultDirection = try values.decodeIfPresent(
+ PlexLibrarySortDirection.self,
+ forKey: .defaultDirection
+ ) ?? .ascending
+ defaultSelectionDirection = try values.decodeIfPresent(
+ PlexLibrarySortDirection.self,
+ forKey: .defaultSelectionDirection
+ )
+ }
+
+ func selection(direction: PlexLibrarySortDirection? = nil) -> PlexLibrarySortSelection? {
+ let resolvedDirection = direction ?? defaultDirection
+ let queryValue: String
+ switch resolvedDirection {
+ case .ascending:
+ queryValue = id
+ case .descending:
+ guard let descendingKey else {
+ return nil
+ }
+ queryValue = descendingKey
+ }
+ return PlexLibrarySortSelection(
+ sortID: id,
+ direction: resolvedDirection,
+ queryValue: queryValue
+ )
+ }
+}
+
+extension PlexLibrarySortDirection: Decodable {}
+
+struct PlexLibrarySortSelection: Equatable, Hashable, Sendable {
+ let sortID: String
+ let direction: PlexLibrarySortDirection
+ let queryValue: String
+}
+
+struct PlexLibraryBrowseOptions: Equatable, Hashable, Sendable {
+ static let `default` = PlexLibraryBrowseOptions()
+
+ var contentTypePath: String?
+ var sort: PlexLibrarySortSelection?
+ var enabledBooleanFilterIDs: Set
+ var valueFilterSelections: [PlexLibraryFilterValue]
+
+ init(
+ contentTypePath: String? = nil,
+ sort: PlexLibrarySortSelection? = nil,
+ enabledBooleanFilterIDs: Set = [],
+ valueFilterSelections: [PlexLibraryFilterValue] = []
+ ) {
+ self.contentTypePath = contentTypePath
+ self.sort = sort
+ self.enabledBooleanFilterIDs = enabledBooleanFilterIDs
+ self.valueFilterSelections = valueFilterSelections.sorted(by: Self.selectionOrder)
+ }
+
+ var queryItems: [URLQueryItem] {
+ var items: [URLQueryItem] = []
+ if let sort {
+ items.append(URLQueryItem(name: "sort", value: sort.queryValue))
+ }
+ items += enabledBooleanFilterIDs.sorted().map {
+ URLQueryItem(name: $0, value: "1")
+ }
+ let selectionsByQueryName = Dictionary(grouping: valueFilterSelections, by: \.queryName)
+ items += selectionsByQueryName.keys.sorted().compactMap { queryName in
+ guard let selections = selectionsByQueryName[queryName] else {
+ return nil
+ }
+ let values = Set(selections.map(\.queryValue)).sorted()
+ guard !values.isEmpty else {
+ return nil
+ }
+ return URLQueryItem(name: queryName, value: values.joined(separator: ","))
+ }
+ return items
+ }
+
+ var hasFilters: Bool {
+ !enabledBooleanFilterIDs.isEmpty || !valueFilterSelections.isEmpty
+ }
+
+ func valueSelections(for filterID: String) -> [PlexLibraryFilterValue] {
+ valueFilterSelections.filter { $0.filterID == filterID }
+ }
+
+ mutating func setValueSelections(
+ _ selections: [PlexLibraryFilterValue],
+ for filterID: String
+ ) {
+ valueFilterSelections.removeAll { $0.filterID == filterID }
+ valueFilterSelections.append(contentsOf: selections)
+ valueFilterSelections.sort(by: Self.selectionOrder)
+ }
+
+ private static func selectionOrder(
+ _ lhs: PlexLibraryFilterValue,
+ _ rhs: PlexLibraryFilterValue
+ ) -> Bool {
+ if lhs.filterID != rhs.filterID {
+ return lhs.filterID < rhs.filterID
+ }
+ if lhs.queryName != rhs.queryName {
+ return lhs.queryName < rhs.queryName
+ }
+ return lhs.queryValue < rhs.queryValue
+ }
+}
+
+struct PlexLibraryBrowseDefinition: Equatable, Sendable {
+ let contentPath: String
+ let filters: [PlexLibraryFilterDefinition]
+ let sorts: [PlexLibrarySortDefinition]
+ var types: [PlexLibraryBrowseType] = []
+
+ func selecting(_ path: String?) throws -> PlexLibraryBrowseDefinition {
+ guard let path else { return self }
+ guard let type = types.first(where: { $0.key == path }) else {
+ throw PlexAPIError.invalidResponse
+ }
+ return PlexLibraryBrowseDefinition(
+ contentPath: type.key, filters: type.filters, sorts: type.sorts, types: types
+ )
+ }
+
+ var booleanFilters: [PlexLibraryFilterDefinition] {
+ filters.filter { $0.valueType == .boolean }
+ }
+
+ var valueFilters: [PlexLibraryFilterDefinition] {
+ filters.filter {
+ ($0.valueType == .string || $0.valueType == .integer)
+ && $0.valuesPath != nil
+ }
+ }
+}
+
+struct PlexLibraryFilterValuesEnvelope: Decodable, Sendable {
+ let mediaContainer: PlexLibraryFilterValuesContainer
+
+ private enum CodingKeys: String, CodingKey {
+ case mediaContainer = "MediaContainer"
+ }
+}
+
+struct PlexLibraryFilterValuesContainer: Decodable, Sendable {
+ let directories: [PlexLibraryFilterValueDirectory]
+
+ private enum CodingKeys: String, CodingKey {
+ case directories = "Directory"
+ }
+
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ directories = try values.decodeIfPresent(
+ [PlexLibraryFilterValueDirectory].self,
+ forKey: .directories
+ ) ?? []
+ }
+
+ func values(for definition: PlexLibraryFilterDefinition) throws -> [PlexLibraryFilterValue] {
+ var seenIDs: Set = []
+ return try directories.map { directory in
+ let value = try directory.value(for: definition)
+ guard seenIDs.insert(value.id).inserted else {
+ throw PlexAPIError.invalidResponse
+ }
+ return value
+ }
+ }
+}
+
+struct PlexLibraryFilterValueDirectory: Decodable, Sendable {
+ let key: String?
+ let filter: String?
+ let title: String?
+ let tag: String?
+
+ private enum CodingKeys: String, CodingKey {
+ case key
+ case filter
+ case title
+ case tag
+ }
+
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ key = values.decodePlexStringIfPresent(forKey: .key)?.nilIfBlank
+ filter = try values.decodeIfPresent(String.self, forKey: .filter)?.nilIfBlank
+ title = try values.decodeIfPresent(String.self, forKey: .title)?.nilIfBlank
+ tag = try values.decodeIfPresent(String.self, forKey: .tag)?.nilIfBlank
+ }
+
+ func value(for definition: PlexLibraryFilterDefinition) throws -> PlexLibraryFilterValue {
+ let queryName: String
+ let queryValue: String
+
+ if let filter,
+ let separator = filter.firstIndex(of: "=") {
+ queryName = String(filter[.. PlexLibraryBrowseRoute? {
+ browseRoutesByLibraryID[libraryID]
+ }
+
+ var supportsWatchedStateMutation: Bool {
+ scrobblePath != nil && unscrobblePath != nil
+ }
+
+ var supportsPlayQueues: Bool {
+ playQueuePath != nil
+ }
+
+ var supportsRating: Bool {
+ ratePath != nil
+ }
+
+ var supportsMetadataRefresh: Bool {
+ canManage && metadataPath != nil
+ }
+
+ var supportsRemoveFromContinueWatching: Bool {
+ removeFromContinueWatchingPath != nil
+ }
+
+ var supportsCollectionManagement: Bool {
+ canManage && collectionPath != nil && metadataPath != nil
+ }
+
+ var supportsPlaylists: Bool {
+ playlistPath != nil
+ }
+
+ var supportsPlaylistManagement: Bool {
+ supportsPlaylists && playlistReadOnly != true
+ }
+}
+
+struct PlexLibraryBrowseRoute: Equatable, Sendable {
+ let sectionPath: String
+ let contentPath: String
+}
+
+struct PlexMediaProvidersEnvelope: Decodable, Sendable {
+ let mediaContainer: PlexMediaProvidersContainer
+
+ enum CodingKeys: String, CodingKey {
+ case mediaContainer = "MediaContainer"
+ }
+}
+
+struct PlexMediaProvidersContainer: Decodable, Sendable {
+ let providers: [PlexMediaProvider]
+ let allowSync: Bool?
+
+ enum CodingKeys: String, CodingKey {
+ case providers = "MediaProvider"
+ case allowSync
+ }
+
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ providers = try values.decodeIfPresent([PlexMediaProvider].self, forKey: .providers) ?? []
+ allowSync = values.decodePlexBoolIfPresent(forKey: .allowSync)
+ }
+
+ func libraryProviderEndpoints() throws -> PlexLibraryProviderEndpoints {
+ guard let provider = providers.first(where: {
+ $0.identifier == PlexMediaProvider.libraryIdentifier
+ }) else {
+ throw PlexAPIError.missingLibraryProvider
+ }
+
+ let timeline = provider.features.first(where: {
+ $0.type.caseInsensitiveCompare("timeline") == .orderedSame
+ })
+ let content = provider.features.first(where: {
+ $0.type.caseInsensitiveCompare("content") == .orderedSame
+ })
+ var browseRoutesByLibraryID: [String: PlexLibraryBrowseRoute] = [:]
+ for directory in content?.directories ?? [] {
+ guard let libraryID = directory.id,
+ let sectionPath = directory.key,
+ let libraryPivot = directory.pivots.first(where: {
+ $0.id.caseInsensitiveCompare("library") == .orderedSame
+ && $0.type.caseInsensitiveCompare("list") == .orderedSame
+ }),
+ let contentPath = libraryPivot.key.nilIfBlank else {
+ continue
+ }
+ guard browseRoutesByLibraryID[libraryID] == nil else {
+ throw PlexAPIError.invalidResponse
+ }
+ browseRoutesByLibraryID[libraryID] = PlexLibraryBrowseRoute(
+ sectionPath: sectionPath,
+ contentPath: contentPath
+ )
+ }
+ let promotedPath = provider.features.first(where: {
+ $0.type.caseInsensitiveCompare("promoted") == .orderedSame
+ })?.key?.nilIfBlank
+ let continueWatchingPath = provider.features.first(where: {
+ $0.type.caseInsensitiveCompare("continuewatching") == .orderedSame
+ })?.key?.nilIfBlank
+ let searchPath = provider.features.first(where: {
+ $0.type.caseInsensitiveCompare("search") == .orderedSame
+ })?.key?.nilIfBlank
+ let timelinePath = timeline?.key?.nilIfBlank
+ let scrobblePath = timeline?.scrobbleKey?.nilIfBlank
+ let unscrobblePath = timeline?.unscrobbleKey?.nilIfBlank
+ let ratePath = provider.features.first(where: {
+ $0.type.caseInsensitiveCompare("rate") == .orderedSame
+ })?.key?.nilIfBlank
+ let playQueuePath = provider.features.first(where: {
+ $0.type.caseInsensitiveCompare("playqueue") == .orderedSame
+ })?.key?.nilIfBlank
+ let metadataPath = provider.features.first(where: {
+ $0.type.caseInsensitiveCompare("metadata") == .orderedSame
+ })?.key?.nilIfBlank
+ let removeFromContinueWatchingPath = provider.features.first(where: {
+ $0.type.caseInsensitiveCompare("actions") == .orderedSame
+ })?.actions.first(where: {
+ $0.id.caseInsensitiveCompare("removeFromContinueWatching") == .orderedSame
+ })?.key.nilIfBlank
+ let collectionPath = provider.features.first(where: {
+ $0.type.caseInsensitiveCompare("collection") == .orderedSame
+ })?.key?.nilIfBlank
+ let playlist = provider.features.first(where: {
+ $0.type.caseInsensitiveCompare("playlist") == .orderedSame
+ })
+ let playlistPath = playlist?.key?.nilIfBlank
+ let playlistReadOnly = playlist?.readOnly
+ let canManage = provider.features.contains {
+ $0.type.caseInsensitiveCompare("manage") == .orderedSame
+ }
+ let supportsDownloadSubscriptions = provider.features.contains {
+ $0.type.caseInsensitiveCompare("subscribe") == .orderedSame
+ && $0.flavor?.caseInsensitiveCompare("download") == .orderedSame
+ }
+
+ return PlexLibraryProviderEndpoints(
+ providerIdentifier: provider.identifier,
+ browseRoutesByLibraryID: browseRoutesByLibraryID,
+ promotedPath: promotedPath,
+ continueWatchingPath: continueWatchingPath,
+ searchPath: searchPath,
+ timelinePath: timelinePath,
+ scrobblePath: scrobblePath,
+ unscrobblePath: unscrobblePath,
+ playQueuePath: playQueuePath,
+ ratePath: ratePath,
+ metadataPath: metadataPath,
+ removeFromContinueWatchingPath: removeFromContinueWatchingPath,
+ collectionPath: collectionPath,
+ playlistPath: playlistPath,
+ playlistReadOnly: playlistReadOnly,
+ canManage: canManage,
+ serverAllowsSync: allowSync,
+ supportsDownloadSubscriptions: supportsDownloadSubscriptions
+ )
+ }
+}
+
+struct PlexMediaProvider: Decodable, Sendable {
+ static let libraryIdentifier = "com.plexapp.plugins.library"
+
+ let identifier: String
+ let features: [PlexMediaProviderFeature]
+
+ enum CodingKeys: String, CodingKey {
+ case identifier
+ case features = "Feature"
+ }
+
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ identifier = try values.decode(String.self, forKey: .identifier)
+ features = try values.decodeIfPresent([PlexMediaProviderFeature].self, forKey: .features) ?? []
+ }
+}
+
+struct PlexMediaProviderFeature: Decodable, Sendable {
+ let type: String
+ let flavor: String?
+ let key: String?
+ let scrobbleKey: String?
+ let unscrobbleKey: String?
+ let readOnly: Bool?
+ let actions: [PlexMediaProviderAction]
+ let directories: [PlexMediaProviderDirectory]
+
+ enum CodingKeys: String, CodingKey {
+ case type
+ case flavor
+ case key
+ case scrobbleKey
+ case unscrobbleKey
+ case readOnly
+ case readonly
+ case actions = "Action"
+ case directories = "Directory"
+ }
+
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ type = try values.decode(String.self, forKey: .type)
+ flavor = try values.decodeIfPresent(String.self, forKey: .flavor)
+ key = try values.decodeIfPresent(String.self, forKey: .key)
+ scrobbleKey = try values.decodeIfPresent(String.self, forKey: .scrobbleKey)
+ unscrobbleKey = try values.decodeIfPresent(String.self, forKey: .unscrobbleKey)
+ readOnly = values.decodePlexBoolIfPresent(forKey: .readOnly)
+ ?? values.decodePlexBoolIfPresent(forKey: .readonly)
+ actions = try values.decodeIfPresent([PlexMediaProviderAction].self, forKey: .actions) ?? []
+ directories = try values.decodeIfPresent(
+ [PlexMediaProviderDirectory].self,
+ forKey: .directories
+ ) ?? []
+ }
+}
+
+struct PlexMediaProviderDirectory: Decodable, Sendable {
+ let id: String?
+ let key: String?
+ let pivots: [PlexMediaProviderPivot]
+
+ private enum CodingKeys: String, CodingKey {
+ case id
+ case key
+ case pivots = "Pivot"
+ }
+
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ id = values.decodePlexStringIfPresent(forKey: .id)?.nilIfBlank
+ key = try values.decodeIfPresent(String.self, forKey: .key)?.nilIfBlank
+ pivots = try values.decodeIfPresent([PlexMediaProviderPivot].self, forKey: .pivots) ?? []
+ }
+}
+
+struct PlexMediaProviderPivot: Decodable, Sendable {
+ let id: String
+ let key: String
+ let type: String
+}
+
+struct PlexMediaProviderAction: Decodable, Sendable {
+ let id: String
+ let key: String
+}
diff --git a/PlexBar/Models/PlexMediaRoute.swift b/PlexBar/Models/PlexMediaRoute.swift
new file mode 100644
index 0000000..962bcb1
--- /dev/null
+++ b/PlexBar/Models/PlexMediaRoute.swift
@@ -0,0 +1,164 @@
+import PlexModels
+import Foundation
+
+struct PlexMediaRoute: Hashable, Sendable {
+ let itemID: String
+ let ratingKey: String
+
+ init(item: PlexMediaItem) {
+ itemID = item.id
+ ratingKey = item.ratingKey
+ }
+
+ init?(ratingKey: String?) {
+ guard let ratingKey = ratingKey?.nilIfBlank else {
+ return nil
+ }
+
+ itemID = ratingKey
+ self.ratingKey = ratingKey
+ }
+
+ func matches(_ item: PlexMediaItem) -> Bool {
+ item.id == itemID && item.ratingKey == ratingKey
+ }
+}
+
+struct PlexMediaHierarchyDestination: Hashable, Identifiable, Sendable {
+ let relationship: Relationship
+ let title: String
+ let route: PlexMediaRoute
+
+ var id: String {
+ route.ratingKey
+ }
+
+ enum Relationship: String, Hashable, Sendable {
+ case show = "Show"
+ case season = "Season"
+ case artist = "Artist"
+ case album = "Album"
+ }
+}
+
+extension PlexMediaItem {
+ var hierarchyDestinations: [PlexMediaHierarchyDestination] {
+ let candidates: [(PlexMediaHierarchyDestination.Relationship, String?, String?)] = switch type?.lowercased() {
+ case "episode":
+ if skipParent == true {
+ [(.show, grandparentTitle, grandparentRatingKey)]
+ } else {
+ [(.show, grandparentTitle, grandparentRatingKey), (.season, parentTitle, parentRatingKey)]
+ }
+ case "season":
+ [(.show, parentTitle, parentRatingKey)]
+ case "track":
+ [(.artist, grandparentTitle, grandparentRatingKey), (.album, parentTitle, parentRatingKey)]
+ case "album":
+ [(.artist, parentTitle, parentRatingKey)]
+ default:
+ []
+ }
+
+ var seenRatingKeys: Set = []
+ return candidates.compactMap { relationship, title, ratingKey in
+ guard let title = title?.nilIfBlank,
+ let route = PlexMediaRoute(ratingKey: ratingKey),
+ route.ratingKey != self.ratingKey,
+ seenRatingKeys.insert(route.ratingKey).inserted else {
+ return nil
+ }
+ return PlexMediaHierarchyDestination(
+ relationship: relationship,
+ title: title,
+ route: route
+ )
+ }
+ }
+}
+
+struct PlexHomeHubRoute: Hashable, Sendable {
+ let hubID: String
+
+ init(hub: PlexHub) {
+ hubID = hub.id
+ }
+
+ func matches(_ hub: PlexHub) -> Bool {
+ hub.id == hubID
+ }
+}
+
+struct PlexSearchHubRoute: Hashable, Sendable {
+ let query: String
+ let hubID: String
+ let hubKey: String
+
+ init?(hub: PlexHub, query: String) {
+ guard !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
+ let hubKey = hub.key,
+ !hubKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ return nil
+ }
+
+ self.query = query
+ hubID = hub.id
+ self.hubKey = hubKey
+ }
+
+ func matches(_ hub: PlexHub, query: String) -> Bool {
+ self.query == query && hub.id == hubID && hub.key == hubKey
+ }
+}
+
+struct PlexRelatedHubRoute: Hashable, Sendable {
+ let sourceRatingKey: String
+ let hubID: String
+ let hubKey: String
+
+ init?(sourceItem: PlexMediaItem, hub: PlexHub) {
+ self.init(sourceRatingKey: sourceItem.ratingKey, hub: hub)
+ }
+
+ init?(sourceRatingKey: String, hub: PlexHub) {
+ guard let hubKey = hub.key,
+ !hubKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ return nil
+ }
+
+ self.sourceRatingKey = sourceRatingKey
+ hubID = hub.id
+ self.hubKey = hubKey
+ }
+
+ func matches(sourceRatingKey: String, hub: PlexHub) -> Bool {
+ self.sourceRatingKey == sourceRatingKey
+ && hub.id == hubID
+ && hub.key == hubKey
+ }
+}
+
+struct PlexPersonRoute: Hashable, Sendable {
+ let identifier: String
+ let name: String
+ let thumb: String?
+
+ init?(person: PlexTag) {
+ guard let identifier = person.tagKey?.nilIfBlank ?? person.id.map(String.init),
+ let name = person.tag.nilIfBlank else {
+ return nil
+ }
+
+ self.identifier = identifier
+ self.name = name
+ thumb = person.thumb?.nilIfBlank
+ }
+}
+
+enum PlexNavigationRoute: Hashable, Sendable {
+ case media(PlexMediaRoute)
+ case person(PlexPersonRoute)
+ case homeHub(PlexHomeHubRoute)
+ case searchHub(PlexSearchHubRoute)
+ case relatedHub(PlexRelatedHubRoute)
+}
diff --git a/PlexBar/Models/PlexMediaSelection.swift b/PlexBar/Models/PlexMediaSelection.swift
new file mode 100644
index 0000000..7eb43ca
--- /dev/null
+++ b/PlexBar/Models/PlexMediaSelection.swift
@@ -0,0 +1,287 @@
+import PlexModels
+import Foundation
+
+struct PlexMediaSelectionOption: Equatable, Identifiable, Sendable {
+ let id: Int
+ let title: String
+ let languageTag: String?
+ let isSelected: Bool
+ let isForced: Bool
+ let isHearingImpaired: Bool
+ let isVisualImpaired: Bool
+}
+
+struct PlexMediaSelectionRequestParameters: Equatable, Sendable {
+ let partID: Int
+ let audioStreamID: Int?
+ let subtitleStreamID: Int?
+ let allParts: Bool
+
+ var hasSelection: Bool {
+ audioStreamID != nil || subtitleStreamID != nil
+ }
+
+ var path: String {
+ "/library/parts/\(partID)"
+ }
+
+ var queryItems: [URLQueryItem] {
+ var items: [URLQueryItem] = []
+ if let audioStreamID {
+ items.append(URLQueryItem(name: "audioStreamID", value: String(audioStreamID)))
+ }
+ if let subtitleStreamID {
+ items.append(URLQueryItem(name: "subtitleStreamID", value: String(subtitleStreamID)))
+ }
+ items.append(URLQueryItem(name: "allParts", value: allParts ? "1" : "0"))
+ return items
+ }
+}
+
+struct PlexSubtitleOffsetRequestParameters: Equatable, Sendable {
+ let streamID: Int
+ let milliseconds: Int
+
+ var path: String {
+ "/library/streams/\(streamID)"
+ }
+
+ var queryItems: [URLQueryItem] {
+ [URLQueryItem(name: "offset", value: String(milliseconds))]
+ }
+}
+
+struct PlexSubtitleOffsetSelection: Equatable, Sendable {
+ static let adjustmentStepMilliseconds = 100
+
+ let streamID: Int
+ let milliseconds: Int
+
+ var displayValue: String {
+ guard milliseconds != 0 else { return "0 ms" }
+ let sign = milliseconds > 0 ? "+" : "−"
+ return "\(sign)\(milliseconds.magnitude) ms"
+ }
+
+ func adjusted(by delta: Int) -> Int? {
+ let result = milliseconds.addingReportingOverflow(delta)
+ return result.overflow ? nil : result.partialValue
+ }
+}
+
+struct PlexNativeMediaSelectionAvailability: Equatable, Sendable {
+ let audioOptionCount: Int
+ let subtitleOptionCount: Int
+}
+
+struct PlexNativeMediaSelectionState: Equatable, Sendable {
+ private(set) var generation: UInt = 0
+ private(set) var availability: PlexNativeMediaSelectionAvailability?
+
+ mutating func beginReload() {
+ generation &+= 1
+ availability = nil
+ }
+
+ mutating func accept(
+ _ availability: PlexNativeMediaSelectionAvailability,
+ generation: UInt
+ ) -> Bool {
+ guard generation == self.generation,
+ availability != self.availability else {
+ return false
+ }
+ self.availability = availability
+ return true
+ }
+}
+
+struct PlexPlaybackMediaSelection: Equatable, Sendable {
+ let partID: Int?
+ let audioOptions: [PlexMediaSelectionOption]
+ let subtitleOptions: [PlexMediaSelectionOption]
+ let subtitleOffsetSelection: PlexSubtitleOffsetSelection?
+
+ init(item: PlexMediaItem, source: PlexPlaybackSource) {
+ guard item.media.indices.contains(source.mediaIndex) else {
+ partID = nil
+ audioOptions = []
+ subtitleOptions = []
+ subtitleOffsetSelection = nil
+ return
+ }
+
+ let parts = item.media[source.mediaIndex].parts
+ let part: PlexMediaPart? = if parts.indices.contains(source.partIndex) {
+ parts[source.partIndex]
+ } else if source.partIndex == -1 {
+ parts.first(where: { $0.selected == true }) ?? parts.first
+ } else {
+ nil
+ }
+
+ partID = part?.id
+ audioOptions = Self.options(from: part?.streams ?? [], streamType: 2)
+ subtitleOptions = Self.options(from: part?.streams ?? [], streamType: 3)
+ subtitleOffsetSelection = Self.subtitleOffsetSelection(from: part?.streams ?? [])
+ }
+
+ var hasActionMenuItems: Bool {
+ audioOptions.count > 1 || !subtitleOptions.isEmpty
+ }
+
+ var hasSelectedSubtitle: Bool {
+ subtitleOptions.contains(where: \.isSelected)
+ }
+
+ func canSelectAudioStream(_ streamID: Int) -> Bool {
+ audioOptions.contains { option in
+ option.id == streamID && !option.isSelected
+ }
+ }
+
+ func canSelectSubtitleStream(_ streamID: Int?) -> Bool {
+ let selectedStreamID = subtitleOptions.first(where: \.isSelected)?.id
+ guard selectedStreamID != streamID else {
+ return false
+ }
+ guard let streamID else {
+ return selectedStreamID != nil
+ }
+ return subtitleOptions.contains { $0.id == streamID }
+ }
+
+ private static func options(
+ from streams: [PlexMediaStream],
+ streamType: Int
+ ) -> [PlexMediaSelectionOption] {
+ streams.compactMap { stream in
+ guard stream.streamType == streamType, let id = stream.id else {
+ return nil
+ }
+ return PlexMediaSelectionOption(
+ id: id,
+ title: stream.selectionTitle,
+ languageTag: stream.nowPlayingLanguageTag,
+ isSelected: stream.selected == true,
+ isForced: stream.forced == true,
+ isHearingImpaired: stream.hearingImpaired == true,
+ isVisualImpaired: stream.visualImpaired == true
+ )
+ }
+ }
+
+ private static func subtitleOffsetSelection(
+ from streams: [PlexMediaStream]
+ ) -> PlexSubtitleOffsetSelection? {
+ let textSubtitleCodecs = Set(["ass", "srt", "ssa", "subrip", "vtt", "webvtt"])
+ guard let stream = streams.first(where: { stream in
+ stream.streamType == 3
+ && stream.selected == true
+ && stream.location?.lowercased() == "external"
+ && stream.codec.map { textSubtitleCodecs.contains($0.lowercased()) } == true
+ }), let streamID = stream.id else {
+ return nil
+ }
+ return PlexSubtitleOffsetSelection(
+ streamID: streamID,
+ milliseconds: stream.offset ?? 0
+ )
+ }
+}
+
+struct PlexServerManagedMediaSelection: Equatable, Sendable {
+ let audioOptions: [PlexMediaSelectionOption]
+ let subtitleOptions: [PlexMediaSelectionOption]
+
+ init(
+ selection: PlexPlaybackMediaSelection,
+ nativeAvailability: PlexNativeMediaSelectionAvailability?
+ ) {
+ guard let nativeAvailability else {
+ audioOptions = []
+ subtitleOptions = []
+ return
+ }
+
+ audioOptions = nativeAvailability.audioOptionCount >= selection.audioOptions.count
+ || selection.audioOptions.count < 2
+ ? []
+ : selection.audioOptions
+ subtitleOptions = nativeAvailability.subtitleOptionCount >= selection.subtitleOptions.count
+ ? []
+ : selection.subtitleOptions
+ }
+
+ init() {
+ audioOptions = []
+ subtitleOptions = []
+ }
+
+ var hasChoices: Bool {
+ !audioOptions.isEmpty || !subtitleOptions.isEmpty
+ }
+
+ func canSelectAudioStream(_ streamID: Int) -> Bool {
+ audioOptions.contains { option in
+ option.id == streamID && !option.isSelected
+ }
+ }
+
+ func canSelectSubtitleStream(_ streamID: Int?) -> Bool {
+ let selectedStreamID = subtitleOptions.first(where: \.isSelected)?.id
+ guard selectedStreamID != streamID else {
+ return false
+ }
+ guard let streamID else {
+ return selectedStreamID != nil
+ }
+ return subtitleOptions.contains { $0.id == streamID }
+ }
+}
+
+private extension PlexMediaStream {
+ var nowPlayingLanguageTag: String? {
+ guard let languageCode = languageCode?.nilIfBlank,
+ languageCode.range(
+ of: #"^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$"#,
+ options: .regularExpression
+ ) != nil else {
+ return nil
+ }
+ return languageCode
+ }
+
+ var selectionTitle: String {
+ var facts: [String] = []
+ if let displayTitle = displayTitle?.nilIfBlank {
+ facts.append(displayTitle)
+ } else if let title = title?.nilIfBlank {
+ facts.append(title)
+ } else if let language = language?.nilIfBlank {
+ facts.append(language)
+ } else if let languageCode = languageCode?.nilIfBlank {
+ facts.append(languageCode.uppercased())
+ } else {
+ facts.append("Unknown")
+ }
+
+ if let codec = codec?.nilIfBlank,
+ !facts.contains(where: { $0.localizedCaseInsensitiveContains(codec) }) {
+ facts.append(codec.uppercased())
+ }
+ if let channels, channels > 0 {
+ facts.append("\(channels) ch")
+ }
+ if forced == true {
+ facts.append("Forced")
+ }
+ if hearingImpaired == true {
+ facts.append("SDH")
+ }
+ if visualImpaired == true {
+ facts.append("Audio Description")
+ }
+ return facts.joined(separator: " · ")
+ }
+}
diff --git a/PlexBar/Models/PlexPlayQueue.swift b/PlexBar/Models/PlexPlayQueue.swift
new file mode 100644
index 0000000..eee316f
--- /dev/null
+++ b/PlexBar/Models/PlexPlayQueue.swift
@@ -0,0 +1,649 @@
+import PlexModels
+import Foundation
+
+enum PlexContinuousPlayQueueType: String, Sendable {
+ case audio
+ case video
+}
+
+enum PlexPlayQueueInsertion: Sendable {
+ case next
+ case upNext
+
+ var queryValue: String {
+ switch self {
+ case .next: "1"
+ case .upNext: "0"
+ }
+ }
+}
+
+enum PlexPlayQueueItemMoveDirection: Sendable {
+ case up
+ case down
+
+ var indexDelta: Int {
+ switch self {
+ case .up: -1
+ case .down: 1
+ }
+ }
+}
+
+struct PlexPlayQueueItemMove: Equatable, Sendable {
+ let playQueueItemID: String
+ let afterPlayQueueItemID: String
+}
+
+enum PlexPlaybackQueuePurpose: Equatable, Sendable {
+ case standard
+ case cinemaPreplay(primaryRatingKey: String)
+}
+
+extension PlexMediaItem {
+ var continuousPlayQueueType: PlexContinuousPlayQueueType? {
+ switch type?.lowercased() {
+ case "show", "season", "episode": .video
+ case "track": .audio
+ default: nil
+ }
+ }
+
+ var continuousPlayQueueUsesOnDeck: Bool {
+ switch type?.lowercased() {
+ case "show", "season": true
+ default: false
+ }
+ }
+
+ var supportsHierarchyPlayback: Bool {
+ continuousPlayQueueUsesOnDeck && key?.nilIfBlank?.hasPrefix("/") == true
+ }
+
+ var queueablePlayQueueType: PlexContinuousPlayQueueType? {
+ switch type?.lowercased() {
+ case "movie", "show", "season", "episode", "clip": .video
+ case "artist", "album", "track": .audio
+ default:
+ nil
+ }
+ }
+}
+
+enum PlexPlaybackQueueDirection: Sendable {
+ case previous
+ case next
+
+ var indexDelta: Int {
+ switch self {
+ case .previous: -1
+ case .next: 1
+ }
+ }
+}
+
+struct PlexPlaybackQueue: Equatable, Sendable {
+ let id: Int
+ let purpose: PlexPlaybackQueuePurpose
+ private(set) var version: Int?
+ private(set) var totalCount: Int
+ private(set) var windowOffset: Int
+ private(set) var items: [PlexMediaItem]
+ private(set) var currentIndex: Int
+ private(set) var isShuffled: Bool
+ private(set) var hasUpNextRegion: Bool
+
+ init(
+ page: PlexPlayQueuePage,
+ selectedRatingKey: String,
+ purpose: PlexPlaybackQueuePurpose = .standard
+ ) throws {
+ guard Self.hasStableUniqueQueueItemIDs(page.items) else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+ let selectedIndex = page.items.firstIndex {
+ $0.playQueueItemID == page.selectedItemID
+ } ?? page.items.firstIndex {
+ $0.ratingKey == selectedRatingKey
+ }
+ guard let selectedIndex else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+
+ id = page.id
+ self.purpose = purpose
+ version = page.version
+ items = page.items
+ currentIndex = selectedIndex
+ windowOffset = page.offset
+ ?? page.selectedItemOffset.map { max($0 - selectedIndex, 0) }
+ ?? 0
+ totalCount = page.totalCount ?? max(windowOffset + page.items.count, page.items.count)
+ isShuffled = page.isShuffled ?? false
+ hasUpNextRegion = page.lastAddedItemID?.nilIfBlank != nil
+ }
+
+ var currentItem: PlexMediaItem {
+ items[currentIndex]
+ }
+
+ var previousItem: PlexMediaItem? {
+ let index = currentIndex - 1
+ return items.indices.contains(index) ? items[index] : nil
+ }
+
+ var nextItem: PlexMediaItem? {
+ let index = currentIndex + 1
+ return items.indices.contains(index) ? items[index] : nil
+ }
+
+ var currentAbsoluteIndex: Int {
+ windowOffset + currentIndex
+ }
+
+ var presentation: PlexPlaybackQueuePresentation {
+ let upcomingItems: [PlexMediaItem]
+ if items.indices.contains(currentIndex + 1) {
+ upcomingItems = Array(items[(currentIndex + 1)...])
+ } else {
+ upcomingItems = []
+ }
+
+ return PlexPlaybackQueuePresentation(
+ currentItem: currentItem,
+ upcomingItems: upcomingItems,
+ currentPosition: currentAbsoluteIndex + 1,
+ totalCount: totalCount,
+ isShuffled: isShuffled,
+ canRemoveUpcomingItems: purpose == .standard,
+ canReorderUpcomingItems: canReorderLoadedUpcomingItems
+ )
+ }
+
+ var canMovePrevious: Bool {
+ currentAbsoluteIndex > 0
+ }
+
+ var canMoveNext: Bool {
+ currentAbsoluteIndex + 1 < totalCount
+ }
+
+ var canChangeShuffle: Bool {
+ purpose == .standard && totalCount > 1 && !hasUpNextRegion
+ }
+
+ var canRepeatAll: Bool {
+ purpose == .standard && totalCount > 1
+ }
+
+ var canReorderLoadedUpcomingItems: Bool {
+ purpose == .standard && items.count - currentIndex - 1 > 1
+ }
+
+ var isCurrentCinemaPreplayItem: Bool {
+ guard case .cinemaPreplay(let primaryRatingKey) = purpose else {
+ return false
+ }
+ return currentItem.ratingKey != primaryRatingKey
+ }
+
+ var isCinemaPreplayQueue: Bool {
+ if case .cinemaPreplay = purpose {
+ return true
+ }
+ return false
+ }
+
+ func canAdd(_ item: PlexMediaItem) -> Bool {
+ guard purpose == .standard,
+ item.key?.nilIfBlank?.hasPrefix("/") == true,
+ let currentType = currentItem.queueablePlayQueueType else {
+ return false
+ }
+ return item.queueablePlayQueueType == currentType
+ }
+
+ func canRemoveUpcomingItem(playQueueItemID: String) -> Bool {
+ guard purpose == .standard,
+ let playQueueItemID = playQueueItemID.nilIfBlank else {
+ return false
+ }
+ return items[(currentIndex + 1)...].contains {
+ $0.playQueueItemID == playQueueItemID
+ }
+ }
+
+ func moveRequest(
+ for playQueueItemID: String,
+ direction: PlexPlayQueueItemMoveDirection
+ ) -> PlexPlayQueueItemMove? {
+ guard let playQueueItemID = playQueueItemID.nilIfBlank,
+ let itemIndex = items.firstIndex(where: {
+ $0.playQueueItemID == playQueueItemID
+ }), itemIndex > currentIndex else {
+ return nil
+ }
+
+ let sourceIndex = itemIndex - currentIndex - 1
+ let destinationIndex = sourceIndex + direction.indexDelta
+ return moveRequest(
+ for: playQueueItemID,
+ toUpcomingIndex: destinationIndex
+ )
+ }
+
+ func moveRequest(
+ fromUpcomingOffsets sourceOffsets: IndexSet,
+ toUpcomingOffset destinationOffset: Int
+ ) -> PlexPlayQueueItemMove? {
+ let upcomingCount = items.count - currentIndex - 1
+ guard sourceOffsets.count == 1,
+ let sourceIndex = sourceOffsets.first,
+ (0.. sourceIndex
+ ? destinationOffset - 1
+ : destinationOffset
+ guard destinationIndex != sourceIndex,
+ (0.. Bool {
+ guard purpose == .standard,
+ let itemIndex = items.firstIndex(where: {
+ $0.playQueueItemID == request.playQueueItemID
+ }),
+ let afterIndex = items.firstIndex(where: {
+ $0.playQueueItemID == request.afterPlayQueueItemID
+ }),
+ itemIndex > currentIndex,
+ afterIndex >= currentIndex,
+ itemIndex != afterIndex,
+ let currentPredecessorID = items[itemIndex - 1]
+ .playQueueItemID?.nilIfBlank else {
+ return false
+ }
+ return currentPredecessorID != request.afterPlayQueueItemID
+ }
+
+ private func moveRequest(
+ for playQueueItemID: String,
+ toUpcomingIndex destinationIndex: Int
+ ) -> PlexPlayQueueItemMove? {
+ let upcomingItems = Array(items.dropFirst(currentIndex + 1))
+ guard purpose == .standard,
+ let playQueueItemID = playQueueItemID.nilIfBlank,
+ let sourceIndex = upcomingItems.firstIndex(where: {
+ $0.playQueueItemID == playQueueItemID
+ }),
+ upcomingItems.indices.contains(destinationIndex),
+ sourceIndex != destinationIndex else {
+ return nil
+ }
+
+ var reorderedItems = upcomingItems
+ let movedItem = reorderedItems.remove(at: sourceIndex)
+ reorderedItems.insert(movedItem, at: destinationIndex)
+ let predecessor = destinationIndex == 0
+ ? currentItem
+ : reorderedItems[destinationIndex - 1]
+
+ guard let afterPlayQueueItemID = predecessor.playQueueItemID?.nilIfBlank else {
+ return nil
+ }
+ return PlexPlayQueueItemMove(
+ playQueueItemID: playQueueItemID,
+ afterPlayQueueItemID: afterPlayQueueItemID
+ )
+ }
+
+ func canMove(_ direction: PlexPlaybackQueueDirection) -> Bool {
+ switch direction {
+ case .previous: canMovePrevious
+ case .next: canMoveNext
+ }
+ }
+
+ func needsWindowRefresh(for direction: PlexPlaybackQueueDirection) -> Bool {
+ guard canMove(direction) else {
+ return false
+ }
+ return !items.indices.contains(currentIndex + direction.indexDelta)
+ }
+
+ mutating func move(_ direction: PlexPlaybackQueueDirection) -> PlexMediaItem? {
+ guard canMove(direction) else {
+ return nil
+ }
+ let destination = currentIndex + direction.indexDelta
+ guard items.indices.contains(destination) else {
+ return nil
+ }
+ currentIndex = destination
+ return currentItem
+ }
+
+ mutating func move(toPlayQueueItemID playQueueItemID: String) -> PlexMediaItem? {
+ guard let destination = items.firstIndex(where: {
+ $0.playQueueItemID == playQueueItemID
+ }), destination != currentIndex else {
+ return nil
+ }
+ currentIndex = destination
+ return currentItem
+ }
+
+ mutating func replaceWindow(
+ with page: PlexPlayQueuePage,
+ centeredOn playQueueItemID: String
+ ) throws {
+ guard page.id == id,
+ Self.hasStableUniqueQueueItemIDs(page.items),
+ let centeredIndex = page.items.firstIndex(where: {
+ $0.playQueueItemID == playQueueItemID
+ }) else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+
+ let absoluteIndex = currentAbsoluteIndex
+ version = page.version
+ totalCount = page.totalCount ?? totalCount
+ items = page.items
+ currentIndex = centeredIndex
+ windowOffset = page.offset ?? max(absoluteIndex - centeredIndex, 0)
+ if let isShuffled = page.isShuffled {
+ self.isShuffled = isShuffled
+ }
+ hasUpNextRegion = page.lastAddedItemID?.nilIfBlank != nil
+ }
+
+ private static func hasStableUniqueQueueItemIDs(
+ _ items: [PlexMediaItem]
+ ) -> Bool {
+ let queueItemIDs = items.compactMap {
+ $0.playQueueItemID?.nilIfBlank
+ }
+ return queueItemIDs.count == items.count
+ && Set(queueItemIDs).count == queueItemIDs.count
+ }
+
+ mutating func applyShuffleMutation(
+ _ page: PlexPlayQueuePage,
+ expectedShuffled: Bool
+ ) throws {
+ guard page.id == id,
+ page.isShuffled == expectedShuffled,
+ let currentPlayQueueItemID = currentItem.playQueueItemID else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+
+ let replacement = try PlexPlaybackQueue(
+ page: page,
+ selectedRatingKey: currentItem.ratingKey,
+ purpose: purpose
+ )
+ guard replacement.currentItem.playQueueItemID == currentPlayQueueItemID else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+ self = replacement
+ }
+
+ mutating func applyReset(_ page: PlexPlayQueuePage) throws {
+ guard page.id == id,
+ let selectedItemID = page.selectedItemID?.nilIfBlank,
+ page.selectedItemOffset == 0 else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+
+ let replacement = try PlexPlaybackQueue(
+ page: page,
+ selectedRatingKey: currentItem.ratingKey,
+ purpose: purpose
+ )
+ guard replacement.currentAbsoluteIndex == 0,
+ replacement.currentItem.playQueueItemID == selectedItemID else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+ self = replacement
+ }
+
+ mutating func applyAddition(_ page: PlexPlayQueuePage) throws {
+ guard page.id == id,
+ let currentPlayQueueItemID = currentItem.playQueueItemID?.nilIfBlank,
+ page.selectedItemID?.nilIfBlank == currentPlayQueueItemID else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+
+ let replacement = try PlexPlaybackQueue(
+ page: page,
+ selectedRatingKey: currentItem.ratingKey,
+ purpose: purpose
+ )
+ guard replacement.currentItem.playQueueItemID == currentPlayQueueItemID else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+ self = replacement
+ }
+
+ mutating func applyRemoval(
+ _ page: PlexPlayQueuePage,
+ removedPlayQueueItemID: String
+ ) throws {
+ guard canRemoveUpcomingItem(playQueueItemID: removedPlayQueueItemID),
+ let currentPlayQueueItemID = currentItem.playQueueItemID?.nilIfBlank else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+
+ let replacement = try mutationReplacement(
+ from: page,
+ currentPlayQueueItemID: currentPlayQueueItemID,
+ fallbackTotalCount: max(totalCount - 1, 1)
+ )
+ guard !replacement.items.contains(where: {
+ $0.playQueueItemID == removedPlayQueueItemID
+ }) else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+ self = replacement
+ }
+
+ mutating func applyMove(
+ _ page: PlexPlayQueuePage,
+ request: PlexPlayQueueItemMove
+ ) throws {
+ guard canApplyMoveRequest(request),
+ let currentPlayQueueItemID = currentItem.playQueueItemID?.nilIfBlank else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+
+ let replacement = try mutationReplacement(
+ from: page,
+ currentPlayQueueItemID: currentPlayQueueItemID,
+ fallbackTotalCount: totalCount
+ )
+ guard let movedIndex = replacement.items.firstIndex(where: {
+ $0.playQueueItemID == request.playQueueItemID
+ }),
+ let afterIndex = replacement.items.firstIndex(where: {
+ $0.playQueueItemID == request.afterPlayQueueItemID
+ }),
+ movedIndex == afterIndex + 1,
+ movedIndex > replacement.currentIndex else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+ self = replacement
+ }
+
+ private func mutationReplacement(
+ from page: PlexPlayQueuePage,
+ currentPlayQueueItemID: String,
+ fallbackTotalCount: Int
+ ) throws -> PlexPlaybackQueue {
+ guard page.id == id,
+ page.selectedItemID?.nilIfBlank == currentPlayQueueItemID,
+ version == nil || page.version == nil || page.version! > version! else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+
+ let normalizedPage = PlexPlayQueuePage(
+ id: page.id,
+ version: page.version,
+ totalCount: page.totalCount ?? fallbackTotalCount,
+ offset: page.offset,
+ selectedItemID: page.selectedItemID,
+ selectedItemOffset: page.selectedItemOffset ?? currentAbsoluteIndex,
+ items: page.items,
+ isShuffled: page.isShuffled ?? isShuffled,
+ lastAddedItemID: page.lastAddedItemID
+ )
+ let replacement = try PlexPlaybackQueue(
+ page: normalizedPage,
+ selectedRatingKey: currentItem.ratingKey,
+ purpose: purpose
+ )
+ guard replacement.currentItem.playQueueItemID == currentPlayQueueItemID else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+ return replacement
+ }
+}
+
+struct PlexPlaybackQueuePresentation: Equatable, Sendable {
+ let currentItem: PlexMediaItem
+ let upcomingItems: [PlexMediaItem]
+ let currentPosition: Int
+ let totalCount: Int
+ let isShuffled: Bool
+ let canRemoveUpcomingItems: Bool
+ let canReorderUpcomingItems: Bool
+
+ var remainingCount: Int {
+ max(totalCount - currentPosition, 0)
+ }
+
+ var unloadedRemainingCount: Int {
+ max(remainingCount - upcomingItems.count, 0)
+ }
+
+ func canMoveUpcomingItem(
+ playQueueItemID: String,
+ direction: PlexPlayQueueItemMoveDirection
+ ) -> Bool {
+ guard canReorderUpcomingItems,
+ let index = upcomingItems.firstIndex(where: {
+ $0.playQueueItemID == playQueueItemID
+ }) else {
+ return false
+ }
+ return upcomingItems.indices.contains(index + direction.indexDelta)
+ }
+}
+
+struct PlexPlayQueuePage: Equatable, Sendable {
+ let id: Int
+ let version: Int?
+ let totalCount: Int?
+ let offset: Int?
+ let selectedItemID: String?
+ let selectedItemOffset: Int?
+ let items: [PlexMediaItem]
+ let isShuffled: Bool?
+ let lastAddedItemID: String?
+
+ init(
+ id: Int,
+ version: Int?,
+ totalCount: Int?,
+ offset: Int?,
+ selectedItemID: String?,
+ selectedItemOffset: Int?,
+ items: [PlexMediaItem],
+ isShuffled: Bool? = nil,
+ lastAddedItemID: String? = nil
+ ) {
+ self.id = id
+ self.version = version
+ self.totalCount = totalCount
+ self.offset = offset
+ self.selectedItemID = selectedItemID
+ self.selectedItemOffset = selectedItemOffset
+ self.items = items
+ self.isShuffled = isShuffled
+ self.lastAddedItemID = lastAddedItemID
+ }
+}
+
+struct PlexPlayQueueEnvelope: Decodable, Sendable {
+ let mediaContainer: PlexPlayQueueContainer
+
+ enum CodingKeys: String, CodingKey {
+ case mediaContainer = "MediaContainer"
+ }
+}
+
+struct PlexPlayQueueContainer: Decodable, Sendable {
+ let playQueueID: Int?
+ let playQueueVersion: Int?
+ let playQueueTotalCount: Int?
+ let playQueueSelectedItemID: String?
+ let playQueueSelectedItemOffset: Int?
+ let playQueueShuffled: Bool?
+ let playQueueLastAddedItemID: String?
+ let offset: Int?
+ let metadata: [PlexMediaItem]
+
+ enum CodingKeys: String, CodingKey {
+ case playQueueID
+ case playQueueVersion
+ case playQueueTotalCount
+ case playQueueSelectedItemID
+ case playQueueSelectedItemOffset
+ case playQueueShuffled
+ case playQueueLastAddedItemID
+ case offset
+ case metadata = "Metadata"
+ }
+
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ playQueueID = values.decodePlexIntIfPresent(forKey: .playQueueID)
+ playQueueVersion = values.decodePlexIntIfPresent(forKey: .playQueueVersion)
+ playQueueTotalCount = values.decodePlexIntIfPresent(forKey: .playQueueTotalCount)
+ playQueueSelectedItemID = values.decodePlexStringIfPresent(forKey: .playQueueSelectedItemID)
+ playQueueSelectedItemOffset = values.decodePlexIntIfPresent(forKey: .playQueueSelectedItemOffset)
+ playQueueShuffled = values.decodePlexBoolIfPresent(forKey: .playQueueShuffled)
+ playQueueLastAddedItemID = values.decodePlexStringIfPresent(
+ forKey: .playQueueLastAddedItemID
+ )
+ offset = values.decodePlexIntIfPresent(forKey: .offset)
+ metadata = try values.decodeIfPresent([PlexMediaItem].self, forKey: .metadata) ?? []
+ }
+
+ func page() throws -> PlexPlayQueuePage {
+ guard let playQueueID, !metadata.isEmpty else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+ return PlexPlayQueuePage(
+ id: playQueueID,
+ version: playQueueVersion,
+ totalCount: playQueueTotalCount,
+ offset: offset,
+ selectedItemID: playQueueSelectedItemID,
+ selectedItemOffset: playQueueSelectedItemOffset,
+ items: metadata,
+ isShuffled: playQueueShuffled,
+ lastAddedItemID: playQueueLastAddedItemID
+ )
+ }
+}
diff --git a/PlexBar/Models/PlexPlayback.swift b/PlexBar/Models/PlexPlayback.swift
new file mode 100644
index 0000000..78ef164
--- /dev/null
+++ b/PlexBar/Models/PlexPlayback.swift
@@ -0,0 +1,1259 @@
+import PlexModels
+import Foundation
+
+enum PlexPlaybackMediaKind: String, Equatable, Sendable {
+ case video
+ case music
+
+ init(media: PlexMediaVersion) {
+ self = media.videoCodec?.nilIfBlank == nil
+ && media.audioCodec?.nilIfBlank != nil
+ ? .music
+ : .video
+ }
+
+ var decisionPath: String {
+ "/\(rawValue)/:/transcode/universal/decision"
+ }
+
+ var startPath: String {
+ "/\(rawValue)/:/transcode/universal/start.m3u8"
+ }
+}
+
+enum PlexNativeSkippingMode: Equatable, Sendable {
+ case time
+ case item
+}
+
+struct PlexNativeSkippingConfiguration: Equatable, Sendable {
+ let mode: PlexNativeSkippingMode
+ let isBackwardEnabled: Bool
+ let isForwardEnabled: Bool
+
+ init(
+ mediaKind: PlexPlaybackMediaKind,
+ canMovePrevious: Bool,
+ canMoveNext: Bool,
+ controlsEnabled: Bool
+ ) {
+ let usesItemSkipping = mediaKind == .music
+ && (canMovePrevious || canMoveNext)
+ mode = usesItemSkipping ? .item : .time
+
+ guard controlsEnabled else {
+ isBackwardEnabled = false
+ isForwardEnabled = false
+ return
+ }
+
+ if usesItemSkipping {
+ isBackwardEnabled = canMovePrevious
+ isForwardEnabled = canMoveNext
+ } else {
+ isBackwardEnabled = true
+ isForwardEnabled = true
+ }
+ }
+}
+
+enum PlexVideoDisplayDynamicRange: String, CaseIterable, Identifiable, Sendable {
+ case automatic
+ case standard
+ case constrainedHigh
+ case high
+
+ var id: Self { self }
+
+ var label: String {
+ switch self {
+ case .automatic: "Automatic"
+ case .standard: "Standard Dynamic Range"
+ case .constrainedHigh: "Constrained High Dynamic Range"
+ case .high: "High Dynamic Range"
+ }
+ }
+}
+
+enum PlexVideoScalingMode: String, CaseIterable, Identifiable, Sendable {
+ case fit
+ case fill
+
+ var id: Self { self }
+
+ var label: String {
+ switch self {
+ case .fit: "Fit"
+ case .fill: "Fill"
+ }
+ }
+}
+
+struct PlexPlaybackStreamingPolicy: Equatable, Sendable {
+ static let automatic = Self(
+ allowsDirectPlay: true,
+ allowsDirectStream: true,
+ forceDirectPlay: false
+ )
+
+ let allowsDirectPlay: Bool
+ let allowsDirectStream: Bool
+ let forceDirectPlay: Bool
+
+ init(
+ allowsDirectPlay: Bool,
+ allowsDirectStream: Bool,
+ forceDirectPlay: Bool = false
+ ) {
+ self.allowsDirectPlay = allowsDirectPlay
+ self.allowsDirectStream = allowsDirectStream
+ self.forceDirectPlay = forceDirectPlay
+ }
+}
+
+struct PlexMusicDirectPlayProfile: Hashable, Sendable {
+ let container: String
+ let audioCodec: String
+}
+
+struct PlexPlaybackCapabilities: Equatable, Sendable {
+ let directPlayContainers: Set
+ let directPlayVideoCodecs: Set
+ let directPlayAudioCodecs: Set
+ let directPlayMusicProfiles: Set
+ let hlsStreamingAudioCodecs: Set
+
+ init(
+ directPlayContainers: Set,
+ directPlayVideoCodecs: Set,
+ directPlayAudioCodecs: Set,
+ directPlayMusicProfiles: Set = [],
+ hlsStreamingAudioCodecs: Set = []
+ ) {
+ self.directPlayContainers = directPlayContainers
+ self.directPlayVideoCodecs = directPlayVideoCodecs
+ self.directPlayAudioCodecs = directPlayAudioCodecs
+ self.directPlayMusicProfiles = directPlayMusicProfiles
+ self.hlsStreamingAudioCodecs = hlsStreamingAudioCodecs
+ }
+
+ func clientProfileExtra(for mediaKind: PlexPlaybackMediaKind) -> String {
+ switch mediaKind {
+ case .video:
+ videoClientProfileExtra
+ case .music:
+ musicClientProfileExtra
+ }
+ }
+
+ func downloadClientProfileExtra(for mediaKind: PlexPlaybackMediaKind) -> String {
+ switch mediaKind {
+ case .video:
+ let transcodeTarget =
+ "add-transcode-target(type=videoProfile&context=static&protocol=http" +
+ "&container=mp4&videoCodec=h264&audioCodec=aac" +
+ "&subtitleCodec=mov_text&replace=true)"
+ return [videoDirectPlayProfile, transcodeTarget]
+ .compactMap { $0 }
+ .joined(separator: "+")
+ case .music:
+ let transcodeTarget =
+ "add-transcode-target(type=musicProfile&context=static&protocol=http" +
+ "&container=mp4&audioCodec=aac&replace=true)"
+ return musicDirectPlayProfiles
+ .appending(transcodeTarget)
+ .joined(separator: "+")
+ }
+ }
+
+ /// Returns an exact PMS media-part path only when the selected source is a
+ /// single file whose declared container and codecs independently satisfy
+ /// this device's native AVFoundation and VideoToolbox capability contract.
+ /// Missing facts, multipart media, and selected subtitle streams are not
+ /// guessed; those sources remain owned by PMS's universal decision path.
+ func directPlayPath(
+ for item: PlexMediaItem,
+ source: PlexPlaybackSource
+ ) -> String? {
+ guard item.media.indices.contains(source.mediaIndex), source.partIndex >= 0 else {
+ return nil
+ }
+
+ let media = item.media[source.mediaIndex]
+ guard media.parts.indices.contains(source.partIndex) else {
+ return nil
+ }
+
+ let part = media.parts[source.partIndex]
+ guard let path = part.key?.nilIfBlank,
+ let container = normalized(part.container ?? media.container) else {
+ return nil
+ }
+
+ let mediaKind = PlexPlaybackMediaKind(media: media)
+ switch mediaKind {
+ case .video:
+ guard directPlayContainers.contains(container),
+ let videoCodec = normalized(media.videoCodec),
+ directPlayVideoCodecs.contains(videoCodec) else {
+ return nil
+ }
+ if let audioCodec = normalized(media.audioCodec),
+ !directPlayAudioCodecs.contains(audioCodec) {
+ return nil
+ }
+ case .music:
+ guard let audioCodec = normalized(media.audioCodec),
+ directPlayMusicProfiles.contains(PlexMusicDirectPlayProfile(
+ container: container,
+ audioCodec: audioCodec
+ )) else {
+ return nil
+ }
+ }
+
+ for stream in part.streams where stream.selected == true {
+ guard let codec = normalized(stream.codec) else {
+ return nil
+ }
+ switch stream.streamType {
+ case 1:
+ guard mediaKind == .video,
+ directPlayVideoCodecs.contains(codec) else { return nil }
+ case 2:
+ switch mediaKind {
+ case .video:
+ guard directPlayAudioCodecs.contains(codec) else { return nil }
+ case .music:
+ guard codec == normalized(media.audioCodec) else { return nil }
+ }
+ case 3:
+ return nil
+ default:
+ return nil
+ }
+ }
+
+ return path
+ }
+
+ private func normalized(_ value: String?) -> String? {
+ value?.trimmingCharacters(in: .whitespacesAndNewlines)
+ .lowercased()
+ .nilIfBlank
+ }
+
+ private var videoClientProfileExtra: String {
+ // H.264 remains the conversion target. Copy HEVC only when the native
+ // decoder supports it; Apple HLS requires fragmented MP4 for HEVC.
+ let videoCodecs = directPlayVideoCodecs.contains("hevc") ? "h264,hevc" : "h264"
+ let transcodeTarget =
+ "add-transcode-target(type=videoProfile&context=streaming&protocol=hls" +
+ "&container=mp4&videoCodec=\(videoCodecs)&audioCodec=aac&replace=true)"
+ return [videoDirectPlayProfile, transcodeTarget, hlsStreamingAudioProfile]
+ .compactMap { $0 }
+ .joined(separator: "+")
+ }
+
+ private var musicClientProfileExtra: String {
+ musicDirectPlayProfiles
+ .appending(
+ "add-transcode-target(type=musicProfile&context=streaming&protocol=hls" +
+ "&container=mpegts&audioCodec=aac)"
+ )
+ .joined(separator: "+")
+ }
+
+ private var videoDirectPlayProfile: String? {
+ let containers = directPlayContainers.sorted().joined(separator: ",")
+ let videoCodecs = directPlayVideoCodecs.sorted().joined(separator: ",")
+ let audioCodecs = directPlayAudioCodecs.sorted().joined(separator: ",")
+ guard !containers.isEmpty, !videoCodecs.isEmpty, !audioCodecs.isEmpty else {
+ return nil
+ }
+ return "add-direct-play-profile(type=videoProfile&container=\(containers)" +
+ "&videoCodec=\(videoCodecs)&audioCodec=\(audioCodecs)&subtitleCodec=*)"
+ }
+
+ private var hlsStreamingAudioProfile: String? {
+ let audioCodecs = hlsStreamingAudioCodecs.sorted().joined(separator: ",")
+ guard !audioCodecs.isEmpty else { return nil }
+ return "add-transcode-target-codec(type=videoProfile&context=streaming" +
+ "&protocol=hls&audioCodec=\(audioCodecs))"
+ }
+
+ private var musicDirectPlayProfiles: [String] {
+ directPlayMusicProfiles
+ .sorted {
+ ($0.container, $0.audioCodec) < ($1.container, $1.audioCodec)
+ }
+ .map { profile in
+ "add-direct-play-profile(type=musicProfile&container=\(profile.container)" +
+ "&videoCodec=*&audioCodec=\(profile.audioCodec)&subtitleCodec=*)"
+ }
+ }
+}
+
+private extension Array where Element == String {
+ func appending(_ value: String) -> [String] {
+ self + [value]
+ }
+}
+
+struct PlexPlaybackPlan: Equatable, Sendable {
+ enum Method: String, Equatable, Sendable {
+ case directPlay
+ case directStream
+ case transcode
+ }
+
+ let url: URL
+ let method: Method
+ let mediaKind: PlexPlaybackMediaKind
+ let sessionIdentifier: String
+ let ratingKey: String
+ let duration: TimeInterval?
+ let startTime: TimeInterval
+ let source: PlexPlaybackSource
+ let usesServerMediaSelection: Bool
+ let supportsAudioBoost: Bool
+ let supportsSubtitleAutoSync: Bool
+
+ init(
+ url: URL,
+ method: Method,
+ mediaKind: PlexPlaybackMediaKind,
+ sessionIdentifier: String,
+ ratingKey: String,
+ duration: TimeInterval?,
+ startTime: TimeInterval,
+ source: PlexPlaybackSource,
+ usesServerMediaSelection: Bool,
+ supportsAudioBoost: Bool = false,
+ supportsSubtitleAutoSync: Bool = false
+ ) {
+ self.url = url
+ self.method = method
+ self.mediaKind = mediaKind
+ self.sessionIdentifier = sessionIdentifier
+ self.ratingKey = ratingKey
+ self.duration = duration
+ self.startTime = startTime
+ self.source = source
+ self.usesServerMediaSelection = usesServerMediaSelection
+ self.supportsAudioBoost = supportsAudioBoost
+ self.supportsSubtitleAutoSync = supportsSubtitleAutoSync
+ }
+}
+
+extension PlexPlaybackPlan.Method {
+ var label: String {
+ switch self {
+ case .directPlay: "Direct Play"
+ case .directStream: "Direct Stream"
+ case .transcode: "Transcode"
+ }
+ }
+}
+
+struct PlexPlayerPlaybackInfoPresentation: Equatable, Sendable {
+ let title: String
+ let hierarchyLine: String?
+ let summary: String?
+ let contentRating: String?
+ let genre: String?
+
+ init(item: PlexMediaItem) {
+ title = item.title
+ hierarchyLine = Self.hierarchyLine(for: item)
+ summary = item.summary?.nilIfBlank
+ contentRating = item.contentRating?.nilIfBlank
+ genre = Self.genreLine(for: item)
+ }
+
+ private static func hierarchyLine(for item: PlexMediaItem) -> String? {
+ let values: [String?] = switch item.type?.lowercased() {
+ case "episode", "track": [item.grandparentTitle, item.parentTitle]
+ default: [item.parentTitle, item.grandparentTitle]
+ }
+
+ var seen: Set = []
+ let hierarchy = values
+ .compactMap { $0?.nilIfBlank }
+ .filter { seen.insert($0).inserted }
+ return hierarchy.isEmpty ? nil : hierarchy.joined(separator: " · ")
+ }
+
+ private static func genreLine(for item: PlexMediaItem) -> String? {
+ var seen: Set = []
+ let genres = item.genres
+ .compactMap(\.tag.nilIfBlank)
+ .filter { seen.insert($0).inserted }
+ return genres.isEmpty ? nil : genres.joined(separator: ", ")
+ }
+}
+
+struct PlexPlaybackSource: Codable, Equatable, Sendable {
+ let mediaIndex: Int
+ let partIndex: Int
+}
+
+struct PlexPlaybackVersionOption: Equatable, Identifiable, Sendable {
+ let id: Int
+ let source: PlexPlaybackSource
+ let label: String
+}
+
+struct PlexPlaybackVersionSelection: Equatable, Sendable {
+ let options: [PlexPlaybackVersionOption]
+ let selectedID: Int
+
+ init?(item: PlexMediaItem, selectedSource: PlexPlaybackSource) {
+ let options = item.playbackVersionOptions
+ guard options.count > 1,
+ options.contains(where: { $0.source == selectedSource }) else {
+ return nil
+ }
+ self.options = options
+ selectedID = selectedSource.mediaIndex
+ }
+
+ func canSelect(_ id: Int) -> Bool {
+ id != selectedID && options.contains(where: { $0.id == id })
+ }
+
+ var selectedOption: PlexPlaybackVersionOption? {
+ options.first(where: { $0.id == selectedID })
+ }
+
+ func source(for id: Int) -> PlexPlaybackSource? {
+ guard canSelect(id) else { return nil }
+ return options.first(where: { $0.id == id })?.source
+ }
+}
+
+struct PlexAudioPlaybackPresentation: Equatable, Sendable {
+ let title: String
+ let metadataLines: [String]
+ let artworkPaths: [String]
+
+ init?(item: PlexMediaItem, source: PlexPlaybackSource) {
+ guard item.media.indices.contains(source.mediaIndex) else {
+ return nil
+ }
+
+ let media = item.media[source.mediaIndex]
+ guard PlexPlaybackMediaKind(media: media) == .music else {
+ return nil
+ }
+
+ title = item.title
+ artworkPaths = item.nowPlayingArtworkPaths
+
+ let candidates = [
+ item.grandparentTitle?.nilIfBlank ?? item.originalTitle?.nilIfBlank,
+ item.parentTitle?.nilIfBlank,
+ ]
+ var seen = Set([item.title])
+ metadataLines = candidates.compactMap { candidate in
+ guard let candidate, seen.insert(candidate).inserted else {
+ return nil
+ }
+ return candidate
+ }
+ }
+}
+
+enum PlexPlaybackStartOption: Equatable, Sendable {
+ case resume
+ case beginning
+
+ var startTimeOverride: TimeInterval? {
+ switch self {
+ case .resume:
+ nil
+ case .beginning:
+ 0
+ }
+ }
+}
+
+enum PlexCinemaPreplayRequestPolicy {
+ static func extrasPrefixCount(
+ for item: PlexMediaItem,
+ startOption: PlexPlaybackStartOption,
+ preference: PlexCinemaPreplayPreference
+ ) -> Int? {
+ guard item.type?.lowercased() == "movie",
+ startOption == .beginning else {
+ return nil
+ }
+ return preference.extrasPrefixCount
+ }
+}
+
+struct PlexPlaybackQueueSourcePreference: Equatable, Sendable {
+ let ratingKey: String
+ let source: PlexPlaybackSource
+
+ func source(for item: PlexMediaItem) -> PlexPlaybackSource? {
+ item.ratingKey == ratingKey ? source : nil
+ }
+}
+
+enum PlexPlaybackSkipDirection: Sendable {
+ case backward
+ case forward
+
+ func offset(for interval: TimeInterval) -> TimeInterval? {
+ guard interval.isFinite, interval > 0 else {
+ return nil
+ }
+ switch self {
+ case .backward:
+ return -interval
+ case .forward:
+ return interval
+ }
+ }
+}
+
+enum PlexPlaybackSeek {
+ static let skipInterval: TimeInterval = 10
+
+ static func target(
+ from position: TimeInterval,
+ duration: TimeInterval?,
+ offset: TimeInterval
+ ) -> TimeInterval {
+ let currentPosition = position.isFinite ? max(position, 0) : 0
+ let requestedPosition = offset.isFinite ? currentPosition + offset : currentPosition
+ return clamped(requestedPosition, duration: duration)
+ }
+
+ static func clamped(
+ _ position: TimeInterval,
+ duration: TimeInterval?
+ ) -> TimeInterval {
+ let nonnegativePosition = position.isFinite ? max(position, 0) : 0
+ guard let duration, duration.isFinite, duration > 0 else {
+ return nonnegativePosition
+ }
+ return min(nonnegativePosition, duration)
+ }
+}
+
+struct PlexPlaybackSeekSequence: Sendable {
+ struct Request: Equatable, Sendable {
+ let target: TimeInterval
+ fileprivate let generation: UInt
+ }
+
+ private(set) var pendingTarget: TimeInterval?
+ private var generation: UInt = 0
+
+ mutating func reserve(
+ absoluteTarget: TimeInterval,
+ duration: TimeInterval?
+ ) -> Request {
+ reserve(target: PlexPlaybackSeek.clamped(absoluteTarget, duration: duration))
+ }
+
+ mutating func reserve(
+ relativeOffset: TimeInterval,
+ currentPosition: TimeInterval,
+ duration: TimeInterval?
+ ) -> Request {
+ let target = PlexPlaybackSeek.target(
+ from: pendingTarget ?? currentPosition,
+ duration: duration,
+ offset: relativeOffset
+ )
+ return reserve(target: target)
+ }
+
+ func isCurrent(_ request: Request) -> Bool {
+ request.generation == generation
+ }
+
+ mutating func finish(_ request: Request) -> Bool {
+ guard isCurrent(request) else {
+ return false
+ }
+ pendingTarget = nil
+ return true
+ }
+
+ mutating func invalidate() {
+ generation &+= 1
+ pendingTarget = nil
+ }
+
+ private mutating func reserve(target: TimeInterval) -> Request {
+ generation &+= 1
+ pendingTarget = target
+ return Request(target: target, generation: generation)
+ }
+}
+
+struct PlexPlaybackTimeJumpExpectations: Sendable {
+ struct Token: Equatable, Sendable {
+ fileprivate let generation: UInt
+ }
+
+ private struct Entry: Sendable {
+ let token: Token
+ let target: TimeInterval
+ }
+
+ static let maximumRetainedCount = 8
+ static let matchingTolerance: TimeInterval = 0.25
+
+ private var generation: UInt = 0
+ private var entries: [Entry] = []
+
+ var retainedCount: Int {
+ entries.count
+ }
+
+ mutating func expect(target: TimeInterval) -> Token? {
+ guard target.isFinite, target >= 0 else {
+ return nil
+ }
+
+ generation &+= 1
+ let token = Token(generation: generation)
+ entries.append(Entry(token: token, target: target))
+ if entries.count > Self.maximumRetainedCount {
+ entries.removeFirst(entries.count - Self.maximumRetainedCount)
+ }
+ return token
+ }
+
+ mutating func cancel(_ token: Token?) {
+ guard let token else {
+ return
+ }
+ entries.removeAll { $0.token == token }
+ }
+
+ mutating func consume(position: TimeInterval) -> Bool {
+ guard position.isFinite,
+ let index = entries.indices.min(by: {
+ abs(entries[$0].target - position) < abs(entries[$1].target - position)
+ }),
+ abs(entries[index].target - position) <= Self.matchingTolerance else {
+ return false
+ }
+
+ entries.remove(at: index)
+ return true
+ }
+
+ mutating func invalidate() {
+ generation &+= 1
+ entries.removeAll(keepingCapacity: false)
+ }
+}
+
+struct PlexPlaybackSessionEpoch: Sendable {
+ struct Ticket: Equatable, Sendable {
+ fileprivate let generation: UInt
+ }
+
+ private(set) var isActive = false
+ private var generation: UInt = 0
+
+ mutating func activate() -> Ticket {
+ generation &+= 1
+ isActive = true
+ return Ticket(generation: generation)
+ }
+
+ func currentTicket() -> Ticket? {
+ guard isActive else {
+ return nil
+ }
+ return Ticket(generation: generation)
+ }
+
+ func isCurrent(_ ticket: Ticket) -> Bool {
+ isActive && ticket.generation == generation
+ }
+
+ mutating func invalidate() {
+ generation &+= 1
+ isActive = false
+ }
+}
+
+struct PlexTimelineRequestIdentity: Equatable, Sendable {
+ let sessionIdentifier: String
+ let ticket: PlexPlaybackSessionEpoch.Ticket
+
+ func isCurrent(
+ sessionIdentifier: String,
+ epoch: PlexPlaybackSessionEpoch
+ ) -> Bool {
+ self.sessionIdentifier == sessionIdentifier
+ && epoch.isCurrent(ticket)
+ }
+}
+
+enum PlexPlaybackRate: Float, CaseIterable, Identifiable, Sendable {
+ case half = 0.5
+ case threeQuarters = 0.75
+ case normal = 1
+ case oneAndAQuarter = 1.25
+ case oneAndAHalf = 1.5
+ case oneAndThreeQuarters = 1.75
+ case double = 2
+
+ var id: Self { self }
+
+ var label: String {
+ switch self {
+ case .half: "0.5×"
+ case .threeQuarters: "0.75×"
+ case .normal: "Normal"
+ case .oneAndAQuarter: "1.25×"
+ case .oneAndAHalf: "1.5×"
+ case .oneAndThreeQuarters: "1.75×"
+ case .double: "2×"
+ }
+ }
+
+ init?(remoteCommandValue: Float) {
+ guard let rate = Self.allCases.first(where: {
+ abs($0.rawValue - remoteCommandValue) < 0.001
+ }) else {
+ return nil
+ }
+ self = rate
+ }
+}
+
+enum PlexPlaybackRepeatMode: CaseIterable, Equatable, Identifiable, Sendable {
+ case off
+ case one
+ case all
+
+ var id: Self { self }
+
+ var label: String {
+ switch self {
+ case .off: "Off"
+ case .one: "One"
+ case .all: "All"
+ }
+ }
+}
+
+enum PlexPlaybackCompletionAction: Equatable, Sendable {
+ case stop
+ case replayCurrent
+ case advanceNext
+ case presentPostPlay(autoAdvanceAfterSeconds: Int?)
+ case resetQueue
+
+ static func resolve(
+ repeatMode: PlexPlaybackRepeatMode,
+ canAdvance: Bool,
+ canResetQueue: Bool
+ ) -> Self {
+ if repeatMode == .one {
+ return .replayCurrent
+ }
+ if canAdvance {
+ return .advanceNext
+ }
+ if repeatMode == .all, canResetQueue {
+ return .resetQueue
+ }
+ return .stop
+ }
+
+ static func resolve(
+ repeatMode: PlexPlaybackRepeatMode,
+ canAdvance: Bool,
+ canResetQueue: Bool,
+ completedItem: PlexMediaItem,
+ mediaKind: PlexPlaybackMediaKind,
+ duration: TimeInterval?,
+ autoplayPreferences: PlexAutoplayPreferences,
+ lastInteractionDate: Date,
+ isCinemaPreplayItem: Bool = false,
+ now: Date = Date()
+ ) -> Self {
+ if repeatMode == .one {
+ return .replayCurrent
+ }
+ if isCinemaPreplayItem, canAdvance {
+ return .advanceNext
+ }
+
+ let queueAction = resolve(
+ repeatMode: repeatMode,
+ canAdvance: canAdvance,
+ canResetQueue: canResetQueue
+ )
+ guard queueAction == .advanceNext,
+ mediaKind == .video,
+ completedItem.playlistItemID?.nilIfBlank == nil,
+ !(completedItem.type?.lowercased() == "clip"
+ && completedItem.subtype?.lowercased() == "trailer") else {
+ return queueAction
+ }
+
+ if let duration, duration <= 5 * 60 {
+ return .advanceNext
+ }
+
+ guard autoplayPreferences.isEnabled else {
+ return .presentPostPlay(autoAdvanceAfterSeconds: nil)
+ }
+
+ if let passoutInterval = autoplayPreferences.passoutProtection.interval,
+ let duration,
+ duration > 20 * 60,
+ max(now.timeIntervalSince(lastInteractionDate), 0) > passoutInterval {
+ return .presentPostPlay(autoAdvanceAfterSeconds: nil)
+ }
+
+ let countdownSeconds = autoplayPreferences.countdown.rawValue
+ return countdownSeconds == 0
+ ? .advanceNext
+ : .presentPostPlay(autoAdvanceAfterSeconds: countdownSeconds)
+ }
+}
+
+enum PlexPostPlayPresentationMode: Equatable, Sendable {
+ case none
+ case manual
+ case automatic(afterSeconds: Int)
+ case inactivityConfirmation
+
+ static func resolve(
+ action: PlexPlaybackCompletionAction,
+ autoplayPreferences: PlexAutoplayPreferences
+ ) -> Self {
+ guard case .presentPostPlay(let autoAdvanceAfterSeconds) = action else {
+ return .none
+ }
+ if let autoAdvanceAfterSeconds {
+ return .automatic(afterSeconds: autoAdvanceAfterSeconds)
+ }
+ return autoplayPreferences.isEnabled ? .inactivityConfirmation : .manual
+ }
+}
+
+struct PlexPlaybackReconfigurationPolicy: Equatable, Sendable {
+ let canReload: Bool
+ let autoplay: Bool
+
+ init(status: PlexPlaybackStatus) {
+ switch status {
+ case .preparing, .playing, .buffering:
+ canReload = true
+ autoplay = true
+ case .paused:
+ canReload = true
+ autoplay = false
+ case .idle, .ended, .failed:
+ canReload = false
+ autoplay = false
+ }
+ }
+}
+
+struct PlexVideoQualitySelection: Equatable, Sendable {
+ let selectedQuality: PlexVideoQuality
+ let isVideo: Bool
+ let canChange: Bool
+
+ func canSelect(_ quality: PlexVideoQuality) -> Bool {
+ isVideo && canChange && quality != selectedQuality
+ }
+}
+
+enum PlexPlaybackRecoveryPolicy {
+ static func canRetry(
+ status: PlexPlaybackStatus,
+ isLoading: Bool,
+ isActive: Bool,
+ didStop: Bool
+ ) -> Bool {
+ guard case .failed = status else {
+ return false
+ }
+ return !isLoading && isActive && !didStop
+ }
+}
+
+struct PlexPlaybackRecoveryRequest: Equatable, Sendable {
+ let source: PlexPlaybackSource
+ let videoQuality: PlexVideoQuality
+ let startTime: TimeInterval
+ let forceServerMediaSelection: Bool
+
+ init(
+ plan: PlexPlaybackPlan,
+ videoQuality: PlexVideoQuality,
+ position: TimeInterval
+ ) {
+ source = plan.source
+ self.videoQuality = videoQuality
+ startTime = position.isFinite ? max(position, 0) : 0
+ forceServerMediaSelection = plan.usesServerMediaSelection
+ }
+}
+
+enum PlexVideoQuality: String, CaseIterable, Identifiable, Sendable {
+ case original
+ case fourK20Mbps
+ case fullHD12Mbps
+ case fullHD8Mbps
+ case hd4Mbps
+ case hd2Mbps
+ case sd1500Kbps
+
+ var id: Self { self }
+
+ var label: String {
+ switch self {
+ case .original: "Original"
+ case .fourK20Mbps: "4K · 20 Mbps"
+ case .fullHD12Mbps: "1080p · 12 Mbps"
+ case .fullHD8Mbps: "1080p · 8 Mbps"
+ case .hd4Mbps: "720p · 4 Mbps"
+ case .hd2Mbps: "720p · 2 Mbps"
+ case .sd1500Kbps: "480p · 1.5 Mbps"
+ }
+ }
+
+ var constraints: PlexVideoQualityConstraints? {
+ switch self {
+ case .original:
+ nil
+ case .fourK20Mbps:
+ PlexVideoQualityConstraints(width: 3_840, height: 2_160, bitrate: 20_000)
+ case .fullHD12Mbps:
+ PlexVideoQualityConstraints(width: 1_920, height: 1_080, bitrate: 12_000)
+ case .fullHD8Mbps:
+ PlexVideoQualityConstraints(width: 1_920, height: 1_080, bitrate: 8_000)
+ case .hd4Mbps:
+ PlexVideoQualityConstraints(width: 1_280, height: 720, bitrate: 4_000)
+ case .hd2Mbps:
+ PlexVideoQualityConstraints(width: 1_280, height: 720, bitrate: 2_000)
+ case .sd1500Kbps:
+ PlexVideoQualityConstraints(width: 854, height: 480, bitrate: 1_500)
+ }
+ }
+}
+
+struct PlexVideoQualityPreferences: Equatable, Sendable {
+ let local: PlexVideoQuality
+ let remote: PlexVideoQuality
+
+ func quality(for connectionKind: PlexConnectionKind?) -> PlexVideoQuality {
+ connectionKind == .local ? local : remote
+ }
+}
+
+struct PlexVideoQualityConstraints: Equatable, Sendable {
+ let width: Int
+ let height: Int
+ let bitrate: Int
+}
+
+enum PlexMusicQuality: String, CaseIterable, Identifiable, Sendable {
+ case original
+ case kbps320
+ case kbps256
+ case kbps192
+ case kbps128
+
+ var id: Self { self }
+
+ var label: String {
+ switch self {
+ case .original: "Original"
+ case .kbps320: "320 kbps"
+ case .kbps256: "256 kbps"
+ case .kbps192: "192 kbps"
+ case .kbps128: "128 kbps"
+ }
+ }
+
+ var bitrate: Int? {
+ switch self {
+ case .original: nil
+ case .kbps320: 320
+ case .kbps256: 256
+ case .kbps192: 192
+ case .kbps128: 128
+ }
+ }
+
+ func limits(media: PlexMediaVersion) -> Bool {
+ guard media.videoCodec?.nilIfBlank == nil,
+ media.audioCodec?.nilIfBlank != nil else {
+ return false
+ }
+ guard let bitrate else { return false }
+ guard let sourceBitrate = media.bitrate, sourceBitrate > 0 else {
+ return true
+ }
+ return sourceBitrate > bitrate
+ }
+}
+
+enum PlexAudioBoost: Int, CaseIterable, Identifiable, Sendable {
+ case none = 100
+ case small = 175
+ case large = 225
+ case huge = 300
+
+ var id: Self { self }
+
+ var label: String {
+ switch self {
+ case .none: "None"
+ case .small: "Small"
+ case .large: "Large"
+ case .huge: "Huge"
+ }
+ }
+
+ var percentageLabel: String {
+ "\(rawValue)%"
+ }
+}
+
+struct PlexMusicQualityPreferences: Equatable, Sendable {
+ let remote: PlexMusicQuality
+
+ func quality(for connectionKind: PlexConnectionKind?) -> PlexMusicQuality {
+ connectionKind == .local ? .original : remote
+ }
+}
+
+extension PlexMediaItem {
+ var hasResumePosition: Bool {
+ guard let viewOffset else {
+ return false
+ }
+ return viewOffset > 0
+ }
+
+ var defaultPlaybackSource: PlexPlaybackSource? {
+ guard supportsNativePlayback else {
+ return nil
+ }
+
+ let selectedMediaIndex = media.firstIndex { version in
+ version.selected == true && !version.parts.isEmpty
+ }
+ let firstPlayableMediaIndex = media.firstIndex { !$0.parts.isEmpty }
+ guard let mediaIndex = selectedMediaIndex ?? firstPlayableMediaIndex else {
+ return nil
+ }
+
+ return playbackSource(mediaIndex: mediaIndex)
+ }
+
+ func playbackSource(mediaIndex: Int) -> PlexPlaybackSource? {
+ guard supportsNativePlayback,
+ media.indices.contains(mediaIndex),
+ !media[mediaIndex].parts.isEmpty else {
+ return nil
+ }
+
+ let parts = media[mediaIndex].parts
+ let partIndex: Int
+ if parts.count == 1 {
+ partIndex = parts.firstIndex { $0.selected == true } ?? 0
+ } else {
+ partIndex = -1
+ }
+
+ return PlexPlaybackSource(mediaIndex: mediaIndex, partIndex: partIndex)
+ }
+
+ var playbackVersionOptions: [PlexPlaybackVersionOption] {
+ media.indices.compactMap { mediaIndex in
+ guard let source = playbackSource(mediaIndex: mediaIndex) else {
+ return nil
+ }
+ return PlexPlaybackVersionOption(
+ id: mediaIndex,
+ source: source,
+ label: Self.playbackVersionLabel(
+ media[mediaIndex],
+ number: mediaIndex + 1
+ )
+ )
+ }
+ }
+
+ private static func playbackVersionLabel(
+ _ version: PlexMediaVersion,
+ number: Int
+ ) -> String {
+ var facts: [String] = []
+
+ if let width = version.width, let height = version.height,
+ width > 0, height > 0 {
+ facts.append("\(width) × \(height)")
+ } else if let resolution = version.videoResolution?.nilIfBlank {
+ facts.append(resolution.uppercased())
+ }
+ if let videoCodec = version.videoCodec?.nilIfBlank {
+ facts.append(videoCodec.uppercased())
+ } else if let audioCodec = version.audioCodec?.nilIfBlank {
+ facts.append(audioCodec.uppercased())
+ }
+ if let bitrate = version.bitrate, bitrate > 0 {
+ facts.append(Self.playbackVersionBitrateLabel(bitrate))
+ }
+ if let container = version.container?.nilIfBlank {
+ facts.append(container.uppercased())
+ }
+
+ let prefix = "Version \(number)"
+ return facts.isEmpty ? prefix : "\(prefix) · \(facts.joined(separator: " · "))"
+ }
+
+ private static func playbackVersionBitrateLabel(_ kilobitsPerSecond: Int) -> String {
+ guard kilobitsPerSecond >= 1_000 else {
+ return "\(kilobitsPerSecond) kbps"
+ }
+ let megabitsPerSecond = Double(kilobitsPerSecond) / 1_000
+ return megabitsPerSecond.formatted(
+ .number.precision(.fractionLength(0...1))
+ ) + " Mbps"
+ }
+}
+
+enum PlexTimelineState: String, Codable, Sendable {
+ case stopped
+ case buffering
+ case playing
+ case paused
+}
+
+struct PlexTimelineUpdate: Sendable {
+ let ratingKey: String
+ let state: PlexTimelineState
+ let time: Int
+ let duration: Int
+ let sessionIdentifier: String
+ let playQueueItemID: String?
+ let continuing: Bool?
+ let offline: Bool
+
+ init(
+ ratingKey: String,
+ state: PlexTimelineState,
+ time: Int,
+ duration: Int,
+ sessionIdentifier: String,
+ playQueueItemID: String? = nil,
+ continuing: Bool? = nil,
+ offline: Bool = false
+ ) {
+ self.ratingKey = ratingKey
+ self.state = state
+ self.time = time
+ self.duration = duration
+ self.sessionIdentifier = sessionIdentifier
+ self.playQueueItemID = playQueueItemID
+ self.continuing = continuing
+ self.offline = offline
+ }
+}
+
+struct PlexTimelineResponse: Equatable, Sendable {
+ struct Termination: Equatable, Sendable {
+ let code: Int
+ let text: String?
+
+ var message: String {
+ text?.nilIfBlank ?? "Plex Media Server ended playback (code \(code))."
+ }
+ }
+
+ let termination: Termination?
+}
+
+struct PlexTimelineResponseEnvelope: Decodable, Sendable {
+ let mediaContainer: PlexTimelineResponseContainer
+
+ enum CodingKeys: String, CodingKey {
+ case mediaContainer = "MediaContainer"
+ }
+}
+
+struct PlexTimelineResponseContainer: Decodable, Sendable {
+ let terminationCode: Int?
+ let terminationText: String?
+
+ private enum CodingKeys: String, CodingKey {
+ case terminationCode
+ case terminationText
+ }
+
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ terminationCode = values.decodePlexIntIfPresent(forKey: .terminationCode)
+ terminationText = try values.decodeIfPresent(String.self, forKey: .terminationText)
+ }
+
+ var response: PlexTimelineResponse {
+ PlexTimelineResponse(
+ termination: terminationCode.map {
+ PlexTimelineResponse.Termination(code: $0, text: terminationText)
+ }
+ )
+ }
+}
+
+struct PlexPlaybackDecisionEnvelope: Decodable, Sendable {
+ let mediaContainer: PlexPlaybackDecisionContainer
+
+ enum CodingKeys: String, CodingKey {
+ case mediaContainer = "MediaContainer"
+ }
+}
+
+struct PlexPlaybackDecisionContainer: Decodable, Sendable {
+ let generalDecisionCode: Int?
+ let generalDecisionText: String?
+ let metadata: [PlexMediaItem]
+
+ enum CodingKeys: String, CodingKey {
+ case generalDecisionCode
+ case generalDecisionText
+ case metadata = "Metadata"
+ }
+
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ generalDecisionCode = try Self.decodeDecisionCode(values, key: .generalDecisionCode)
+ generalDecisionText = try values.decodeIfPresent(String.self, forKey: .generalDecisionText)
+ metadata = try values.decodeIfPresent([PlexMediaItem].self, forKey: .metadata) ?? []
+ }
+
+ private static func decodeDecisionCode(
+ _ values: KeyedDecodingContainer,
+ key: CodingKeys
+ ) throws -> Int? {
+ if let value = try? values.decodeIfPresent(Int.self, forKey: key) {
+ return value
+ }
+ if let value = try? values.decodeIfPresent(String.self, forKey: key) {
+ return Int(value)
+ }
+ return nil
+ }
+}
diff --git a/PlexBar/Models/PlexPlaybackChapter.swift b/PlexBar/Models/PlexPlaybackChapter.swift
new file mode 100644
index 0000000..868cfef
--- /dev/null
+++ b/PlexBar/Models/PlexPlaybackChapter.swift
@@ -0,0 +1,80 @@
+import PlexModels
+import Foundation
+
+struct PlexPlaybackChapter: Equatable, Identifiable, Sendable {
+ let id: String
+ let title: String
+ let startTime: TimeInterval
+ let duration: TimeInterval
+ let thumbnailPath: String?
+
+ static func chapters(
+ from chapters: [PlexMediaChapter],
+ mediaDurationMilliseconds: Int?
+ ) -> [Self] {
+ let mediaEnd = mediaDurationMilliseconds.flatMap { duration in
+ duration > 0 ? TimeInterval(duration) / 1_000 : nil
+ }
+
+ let validChapters = chapters.compactMap { chapter -> SourceChapter? in
+ guard let startMilliseconds = chapter.startTimeOffset,
+ let endMilliseconds = chapter.endTimeOffset,
+ startMilliseconds >= 0,
+ endMilliseconds > startMilliseconds else {
+ return nil
+ }
+
+ let startTime = TimeInterval(startMilliseconds) / 1_000
+ let serverEndTime = TimeInterval(endMilliseconds) / 1_000
+ guard mediaEnd.map({ startTime < $0 }) ?? true else {
+ return nil
+ }
+ let endTime = min(serverEndTime, mediaEnd ?? serverEndTime)
+ guard endTime > startTime else {
+ return nil
+ }
+
+ return SourceChapter(
+ sourceID: chapter.id,
+ index: chapter.index,
+ title: chapter.title?.nilIfBlank,
+ startTime: startTime,
+ endTime: endTime,
+ thumbnailPath: chapter.thumb?.nilIfBlank
+ )
+ }
+ .sorted { lhs, rhs in
+ if lhs.startTime != rhs.startTime {
+ return lhs.startTime < rhs.startTime
+ }
+ if lhs.index != rhs.index {
+ return (lhs.index ?? .max) < (rhs.index ?? .max)
+ }
+ return (lhs.sourceID ?? "") < (rhs.sourceID ?? "")
+ }
+
+ return validChapters.enumerated().map { offset, chapter in
+ let chapterNumber = chapter.index.flatMap { $0 > 0 ? $0 : nil } ?? offset + 1
+ return Self(
+ id: [
+ chapter.sourceID ?? "chapter",
+ String(chapterNumber),
+ String(Int(chapter.startTime * 1_000))
+ ].joined(separator: ":"),
+ title: chapter.title ?? "Chapter \(chapterNumber)",
+ startTime: chapter.startTime,
+ duration: chapter.endTime - chapter.startTime,
+ thumbnailPath: chapter.thumbnailPath
+ )
+ }
+ }
+
+ private struct SourceChapter {
+ let sourceID: String?
+ let index: Int?
+ let title: String?
+ let startTime: TimeInterval
+ let endTime: TimeInterval
+ let thumbnailPath: String?
+ }
+}
diff --git a/PlexBar/Models/PlexPlaybackMarker.swift b/PlexBar/Models/PlexPlaybackMarker.swift
new file mode 100644
index 0000000..87a763d
--- /dev/null
+++ b/PlexBar/Models/PlexPlaybackMarker.swift
@@ -0,0 +1,269 @@
+import PlexModels
+import Foundation
+
+enum PlexPlaybackMarkerKind: String, CaseIterable, Equatable, Hashable, Identifiable, Sendable {
+ case intro
+ case commercial
+ case credits
+
+ var id: Self { self }
+
+ var label: String {
+ switch self {
+ case .intro:
+ "Skip Intro"
+ case .commercial:
+ "Skip Ads"
+ case .credits:
+ "Skip Credits"
+ }
+ }
+
+ var settingsLabel: String {
+ switch self {
+ case .intro:
+ "Intros"
+ case .commercial:
+ "Ads"
+ case .credits:
+ "Credits"
+ }
+ }
+}
+
+enum PlexPlaybackMarkerBehavior: String, CaseIterable, Identifiable, Sendable {
+ case disabled
+ case manually
+ case automatically
+
+ var id: Self { self }
+
+ var label: String {
+ switch self {
+ case .disabled:
+ "Disabled"
+ case .manually:
+ "Manually"
+ case .automatically:
+ "Automatically"
+ }
+ }
+}
+
+struct PlexPlaybackMarkerPreferences: Equatable, Sendable {
+ let intro: PlexPlaybackMarkerBehavior
+ let ads: PlexPlaybackMarkerBehavior
+ let credits: PlexPlaybackMarkerBehavior
+
+ func behavior(for kind: PlexPlaybackMarkerKind) -> PlexPlaybackMarkerBehavior {
+ switch kind {
+ case .intro:
+ intro
+ case .commercial:
+ ads
+ case .credits:
+ credits
+ }
+ }
+}
+
+struct PlexPlaybackMarkerAction: Equatable, Identifiable, Sendable {
+ let id: String
+ let kind: PlexPlaybackMarkerKind
+ let startTime: TimeInterval
+ let targetTime: TimeInterval
+
+ var label: String { kind.label }
+
+ var accessibilityHint: String {
+ switch kind {
+ case .intro:
+ "Moves playback to the end of the intro."
+ case .commercial:
+ "Moves playback to the end of the commercial break."
+ case .credits:
+ "Moves playback to the end of the credits."
+ }
+ }
+
+ static func active(
+ in markers: [PlexMediaMarker],
+ at position: TimeInterval,
+ duration: TimeInterval?
+ ) -> Self? {
+ guard position.isFinite, position >= 0 else {
+ return nil
+ }
+
+ return markers.compactMap { marker in
+ action(for: marker, duration: duration)
+ }
+ .filter { action in
+ position >= action.startTime && position < action.targetTime
+ }
+ .min { lhs, rhs in
+ if lhs.startTime != rhs.startTime {
+ return lhs.startTime < rhs.startTime
+ }
+ return lhs.id < rhs.id
+ }
+ }
+
+ static func actions(
+ in markers: [PlexMediaMarker],
+ duration: TimeInterval?
+ ) -> [Self] {
+ markers.compactMap { marker in
+ action(for: marker, duration: duration)
+ }
+ .sorted { lhs, rhs in
+ if lhs.startTime != rhs.startTime {
+ return lhs.startTime < rhs.startTime
+ }
+ return lhs.id < rhs.id
+ }
+ }
+
+ static func availableKinds(
+ in markers: [PlexMediaMarker],
+ duration: TimeInterval?
+ ) -> [PlexPlaybackMarkerKind] {
+ let availableKinds = Set(actions(in: markers, duration: duration).map(\.kind))
+ return PlexPlaybackMarkerKind.allCases.filter(availableKinds.contains)
+ }
+
+ static func manual(
+ in markers: [PlexMediaMarker],
+ at position: TimeInterval,
+ duration: TimeInterval?,
+ preferences: PlexPlaybackMarkerPreferences
+ ) -> Self? {
+ guard let action = active(in: markers, at: position, duration: duration),
+ preferences.behavior(for: action.kind) == .manually else {
+ return nil
+ }
+ return action
+ }
+
+ static func creditsStartTime(
+ in markers: [PlexMediaMarker],
+ duration: TimeInterval?
+ ) -> TimeInterval? {
+ actions(in: markers, duration: duration).compactMap { action in
+ action.kind == .credits ? action.startTime : nil
+ }
+ .min()
+ }
+
+ private static func action(
+ for marker: PlexMediaMarker,
+ duration: TimeInterval?
+ ) -> Self? {
+ let kind: PlexPlaybackMarkerKind
+ switch marker.type.lowercased() {
+ case "intro":
+ kind = .intro
+ case "commercial":
+ kind = .commercial
+ case "credit", "credits":
+ kind = .credits
+ default:
+ return nil
+ }
+
+ guard let startOffset = marker.startTimeOffset,
+ let endOffset = marker.endTimeOffset,
+ startOffset >= 0,
+ endOffset > startOffset else {
+ return nil
+ }
+
+ let startTime = TimeInterval(startOffset) / 1_000
+ var targetTime = TimeInterval(endOffset) / 1_000
+ if let duration, duration.isFinite, duration > 0 {
+ targetTime = min(targetTime, duration)
+ }
+ guard targetTime > startTime else {
+ return nil
+ }
+
+ return Self(
+ id: marker.id ?? "\(kind.rawValue):\(startOffset):\(endOffset)",
+ kind: kind,
+ startTime: startTime,
+ targetTime: targetTime
+ )
+ }
+}
+
+struct PlexPlaybackInterstitial: Equatable, Sendable {
+ let startTime: TimeInterval
+ let duration: TimeInterval
+
+ static func commercials(
+ in markers: [PlexMediaMarker],
+ duration mediaDuration: TimeInterval?
+ ) -> [Self] {
+ let commercialActions = PlexPlaybackMarkerAction.actions(
+ in: markers,
+ duration: mediaDuration
+ ).filter { $0.kind == .commercial }
+
+ return commercialActions.reduce(into: [Self]()) { ranges, action in
+ guard let previous = ranges.last else {
+ ranges.append(Self(
+ startTime: action.startTime,
+ duration: action.targetTime - action.startTime
+ ))
+ return
+ }
+
+ let previousEnd = previous.startTime + previous.duration
+ guard action.startTime <= previousEnd else {
+ ranges.append(Self(
+ startTime: action.startTime,
+ duration: action.targetTime - action.startTime
+ ))
+ return
+ }
+
+ let mergedEnd = max(previousEnd, action.targetTime)
+ ranges[ranges.count - 1] = Self(
+ startTime: previous.startTime,
+ duration: mergedEnd - previous.startTime
+ )
+ }
+ }
+}
+
+struct PlexAutomaticPlaybackMarkerTransition: Equatable, Sendable {
+ private(set) var enteredAction: PlexPlaybackMarkerAction?
+
+ mutating func action(
+ for activeAction: PlexPlaybackMarkerAction?,
+ preferences: PlexPlaybackMarkerPreferences
+ ) -> PlexPlaybackMarkerAction? {
+ guard let activeAction,
+ preferences.behavior(for: activeAction.kind) == .automatically else {
+ enteredAction = nil
+ return nil
+ }
+
+ guard enteredAction != activeAction else {
+ return nil
+ }
+ enteredAction = activeAction
+ return activeAction
+ }
+
+ mutating func retry(_ action: PlexPlaybackMarkerAction) {
+ guard enteredAction == action else {
+ return
+ }
+ enteredAction = nil
+ }
+
+ mutating func reset() {
+ enteredAction = nil
+ }
+}
diff --git a/PlexBar/Models/PlexPlaybackStatus.swift b/PlexBar/Models/PlexPlaybackStatus.swift
new file mode 100644
index 0000000..bdfec6f
--- /dev/null
+++ b/PlexBar/Models/PlexPlaybackStatus.swift
@@ -0,0 +1,74 @@
+import AVFoundation
+
+enum PlexPlaybackStatus: Equatable, Sendable {
+ case idle
+ case preparing
+ case playing
+ case paused
+ case buffering
+ case ended
+ case failed(String)
+}
+
+enum PlexPlaybackTransportAction: Equatable, Sendable {
+ case play
+ case pause
+
+ init?(status: PlexPlaybackStatus) {
+ switch status {
+ case .preparing, .playing, .buffering:
+ self = .pause
+ case .paused:
+ self = .play
+ case .idle, .ended, .failed:
+ return nil
+ }
+ }
+}
+
+enum PlexPlaybackWaitingReason: Equatable, Sendable {
+ case minimizingStalls
+ case evaluatingBufferingRate
+ case noItemToPlay
+ case coordinatedPlayback
+ case interstitialEvent
+
+ init?(
+ timeControlStatus: AVPlayer.TimeControlStatus,
+ nativeReason: AVPlayer.WaitingReason?
+ ) {
+ guard timeControlStatus == .waitingToPlayAtSpecifiedRate,
+ let nativeReason else {
+ return nil
+ }
+
+ if nativeReason == .toMinimizeStalls {
+ self = .minimizingStalls
+ } else if nativeReason == .evaluatingBufferingRate {
+ self = .evaluatingBufferingRate
+ } else if nativeReason == .noItemToPlay {
+ self = .noItemToPlay
+ } else if nativeReason == .waitingForCoordinatedPlayback {
+ self = .coordinatedPlayback
+ } else if nativeReason == .interstitialEvent {
+ self = .interstitialEvent
+ } else {
+ return nil
+ }
+ }
+
+ var diagnosticLabel: String? {
+ switch self {
+ case .minimizingStalls:
+ "Minimizing Stalls"
+ case .evaluatingBufferingRate:
+ nil
+ case .noItemToPlay:
+ "No Item to Play"
+ case .coordinatedPlayback:
+ "Coordinated Playback"
+ case .interstitialEvent:
+ "Interstitial Event"
+ }
+ }
+}
diff --git a/Sources/PlexBar/Models/PlexServerPreview.swift b/PlexBar/Models/PlexServerPreview.swift
similarity index 100%
rename from Sources/PlexBar/Models/PlexServerPreview.swift
rename to PlexBar/Models/PlexServerPreview.swift
diff --git a/PlexBar/Playback/PlexAudioPlayerStage.swift b/PlexBar/Playback/PlexAudioPlayerStage.swift
new file mode 100644
index 0000000..209adee
--- /dev/null
+++ b/PlexBar/Playback/PlexAudioPlayerStage.swift
@@ -0,0 +1,174 @@
+import AppKit
+import SwiftUI
+
+struct PlexAudioPlayerOverlay: Equatable {
+ let presentation: PlexAudioPlaybackPresentation
+ let primaryImageURL: URL?
+ let fallbackImageURL: URL?
+ let token: String
+ let clientContext: PlexClientContext
+
+ init(
+ presentation: PlexAudioPlaybackPresentation,
+ serverURL: URL?,
+ token: String,
+ clientContext: PlexClientContext
+ ) {
+ self.presentation = presentation
+ primaryImageURL = serverURL.flatMap { serverURL in
+ PlexURLBuilder.mediaURL(
+ serverURL: serverURL,
+ path: presentation.artworkPaths.first
+ )
+ }
+ fallbackImageURL = serverURL.flatMap { serverURL in
+ PlexURLBuilder.mediaURL(
+ serverURL: serverURL,
+ path: presentation.artworkPaths.dropFirst().first
+ )
+ }
+ self.token = token
+ self.clientContext = clientContext
+ }
+}
+
+struct PlexAudioPlayerStageContainer: View {
+ let overlay: PlexAudioPlayerOverlay
+
+ var body: some View {
+ PlexAudioPlayerStage(overlay: overlay)
+ .allowsHitTesting(false)
+ }
+}
+
+@MainActor
+final class PlexAudioPlayerStageHost {
+ private(set) var hostingView: NSHostingView?
+
+ func update(in overlayView: NSView?, overlay: PlexAudioPlayerOverlay?) {
+ guard let overlayView, let overlay else {
+ remove()
+ return
+ }
+
+ let hostingView: NSHostingView
+ if let existingHostingView = self.hostingView {
+ hostingView = existingHostingView
+ hostingView.rootView = PlexAudioPlayerStageContainer(overlay: overlay)
+ } else {
+ hostingView = NSHostingView(
+ rootView: PlexAudioPlayerStageContainer(overlay: overlay)
+ )
+ hostingView.sizingOptions = []
+ hostingView.translatesAutoresizingMaskIntoConstraints = false
+ self.hostingView = hostingView
+ }
+
+ guard hostingView.superview !== overlayView else {
+ return
+ }
+
+ hostingView.removeFromSuperview()
+ overlayView.addSubview(hostingView)
+ NSLayoutConstraint.activate([
+ hostingView.leadingAnchor.constraint(equalTo: overlayView.leadingAnchor),
+ hostingView.trailingAnchor.constraint(equalTo: overlayView.trailingAnchor),
+ hostingView.topAnchor.constraint(equalTo: overlayView.topAnchor),
+ hostingView.bottomAnchor.constraint(equalTo: overlayView.bottomAnchor),
+ ])
+ }
+
+ private func remove() {
+ hostingView?.removeFromSuperview()
+ hostingView = nil
+ }
+}
+
+private struct PlexAudioPlayerStage: View {
+ let overlay: PlexAudioPlayerOverlay
+ @ScaledMetric(relativeTo: .largeTitle) private var artworkSize = 260.0
+ @ScaledMetric(relativeTo: .title) private var compactArtworkSize = 200.0
+ @ScaledMetric(relativeTo: .body) private var stageSpacing = 40.0
+ @ScaledMetric(relativeTo: .body) private var stagePadding = 44.0
+ @ScaledMetric(relativeTo: .body) private var controlsClearance = 72.0
+
+ var body: some View {
+ PlexArtworkBackdrop(
+ primaryImageURL: overlay.primaryImageURL,
+ fallbackImageURL: overlay.fallbackImageURL,
+ token: overlay.token,
+ clientContext: overlay.clientContext
+ )
+ .overlay {
+ ViewThatFits(in: .horizontal) {
+ horizontalContent
+ compactContent
+ }
+ .padding(stagePadding)
+ .padding(.bottom, controlsClearance)
+ }
+ }
+
+ private var horizontalContent: some View {
+ HStack(spacing: stageSpacing) {
+ artwork(size: artworkSize)
+
+ PlexAudioPlayerMetadata(
+ presentation: overlay.presentation,
+ alignment: .leading,
+ textAlignment: .leading
+ )
+ .frame(minWidth: 220, maxWidth: 420, alignment: .leading)
+ }
+ }
+
+ private var compactContent: some View {
+ VStack(spacing: stageSpacing / 2) {
+ artwork(size: compactArtworkSize)
+
+ PlexAudioPlayerMetadata(
+ presentation: overlay.presentation,
+ alignment: .center,
+ textAlignment: .center
+ )
+ }
+ }
+
+ private func artwork(size: CGFloat) -> some View {
+ PlexArtworkView(
+ primaryImageURL: overlay.primaryImageURL,
+ fallbackImageURL: overlay.fallbackImageURL,
+ token: overlay.token,
+ clientContext: overlay.clientContext,
+ placeholderSymbol: "music.note",
+ width: size,
+ height: size,
+ cornerRadius: 18
+ )
+ .shadow(color: .black.opacity(0.28), radius: 24, y: 12)
+ .accessibilityHidden(true)
+ }
+}
+
+private struct PlexAudioPlayerMetadata: View {
+ let presentation: PlexAudioPlaybackPresentation
+ let alignment: HorizontalAlignment
+ let textAlignment: TextAlignment
+
+ var body: some View {
+ VStack(alignment: alignment, spacing: 8) {
+ Text(presentation.title)
+ .font(.largeTitle.bold())
+ .lineLimit(2)
+
+ ForEach(presentation.metadataLines, id: \.self) { line in
+ Text(line)
+ .font(.title3)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+ .multilineTextAlignment(textAlignment)
+ .accessibilityElement(children: .combine)
+ }
+}
diff --git a/PlexBar/Playback/PlexContinuousPlayQueueRequest.swift b/PlexBar/Playback/PlexContinuousPlayQueueRequest.swift
new file mode 100644
index 0000000..149c3fe
--- /dev/null
+++ b/PlexBar/Playback/PlexContinuousPlayQueueRequest.swift
@@ -0,0 +1,177 @@
+import PlexModels
+import Foundation
+
+struct PlexCinemaPlayQueueRequest: Equatable, Sendable {
+ let itemKey: String
+ let sourceURI: String
+ let extrasPrefixCount: Int
+
+ init(
+ item: PlexMediaItem,
+ extrasPrefixCount: Int,
+ serverIdentifier: String,
+ providerIdentifier: String = PlexMediaProvider.libraryIdentifier
+ ) throws {
+ guard item.type?.lowercased() == "movie",
+ (0...5).contains(extrasPrefixCount),
+ let itemKey = item.key?.nilIfBlank,
+ itemKey.hasPrefix("/") else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+ self.itemKey = itemKey
+ self.extrasPrefixCount = extrasPrefixCount
+ sourceURI = try PlexMediaSourceURI.item(
+ item,
+ serverIdentifier: serverIdentifier,
+ providerIdentifier: providerIdentifier
+ )
+ }
+
+ var queryItems: [URLQueryItem] {
+ [
+ URLQueryItem(name: "uri", value: sourceURI),
+ URLQueryItem(name: "type", value: PlexContinuousPlayQueueType.video.rawValue),
+ URLQueryItem(name: "key", value: itemKey),
+ URLQueryItem(name: "shuffle", value: "0"),
+ URLQueryItem(name: "repeat", value: "0"),
+ URLQueryItem(name: "continuous", value: "0"),
+ URLQueryItem(name: "extrasPrefixCount", value: String(extrasPrefixCount)),
+ ]
+ }
+}
+
+struct PlexContinuousPlayQueueRequest: Equatable, Sendable {
+ let itemKey: String
+ let queueType: PlexContinuousPlayQueueType
+ let sourceURI: String
+ let usesOnDeck: Bool
+
+ init(
+ item: PlexMediaItem,
+ serverIdentifier: String,
+ providerIdentifier: String = PlexMediaProvider.libraryIdentifier
+ ) throws {
+ guard let queueType = item.continuousPlayQueueType,
+ let itemKey = item.key?.nilIfBlank,
+ itemKey.hasPrefix("/") else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+ self.itemKey = itemKey
+ self.queueType = queueType
+ sourceURI = try PlexMediaSourceURI.item(
+ item,
+ serverIdentifier: serverIdentifier,
+ providerIdentifier: providerIdentifier
+ )
+ usesOnDeck = item.continuousPlayQueueUsesOnDeck
+ }
+
+ var queryItems: [URLQueryItem] {
+ var values = [
+ URLQueryItem(name: "uri", value: sourceURI),
+ URLQueryItem(name: "type", value: queueType.rawValue),
+ URLQueryItem(name: "shuffle", value: "0"),
+ URLQueryItem(name: "repeat", value: "0"),
+ URLQueryItem(name: "continuous", value: "1"),
+ ]
+ values.append(
+ URLQueryItem(
+ name: usesOnDeck ? "onDeck" : "key",
+ value: usesOnDeck ? "1" : itemKey
+ )
+ )
+ return values
+ }
+}
+
+enum PlexPlayQueueMutation: Equatable, Sendable {
+ case shuffled(Bool)
+ case reset
+
+ fileprivate var endpointComponent: String {
+ switch self {
+ case .shuffled(true): "shuffle"
+ case .shuffled(false): "unshuffle"
+ case .reset: "reset"
+ }
+ }
+}
+
+struct PlexPlayQueueMutationRequest: Equatable, Sendable {
+ let queueID: Int
+ let mutation: PlexPlayQueueMutation
+
+ init(queueID: Int, mutation: PlexPlayQueueMutation) throws {
+ guard queueID > 0 else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+ self.queueID = queueID
+ self.mutation = mutation
+ }
+
+ var endpointPathComponents: [String] {
+ [String(queueID), mutation.endpointComponent]
+ }
+}
+
+enum PlexPlayQueueItemMutation: Equatable, Sendable {
+ case remove(playQueueItemID: String)
+ case move(PlexPlayQueueItemMove)
+}
+
+struct PlexPlayQueueItemMutationRequest: Equatable, Sendable {
+ let queueID: Int
+ let mutation: PlexPlayQueueItemMutation
+
+ init(queueID: Int, mutation: PlexPlayQueueItemMutation) throws {
+ guard queueID > 0 else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+ switch mutation {
+ case .remove(let playQueueItemID):
+ guard Self.isValidIdentifier(playQueueItemID) else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+ case .move(let move):
+ guard Self.isValidIdentifier(move.playQueueItemID),
+ Self.isValidIdentifier(move.afterPlayQueueItemID),
+ move.playQueueItemID != move.afterPlayQueueItemID else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+ }
+ self.queueID = queueID
+ self.mutation = mutation
+ }
+
+ var endpointPathComponents: [String] {
+ switch mutation {
+ case .remove(let playQueueItemID):
+ [String(queueID), "items", playQueueItemID]
+ case .move(let move):
+ [String(queueID), "items", move.playQueueItemID, "move"]
+ }
+ }
+
+ var method: String {
+ switch mutation {
+ case .remove: "DELETE"
+ case .move: "PUT"
+ }
+ }
+
+ var queryItems: [URLQueryItem] {
+ switch mutation {
+ case .remove:
+ []
+ case .move(let move):
+ [URLQueryItem(name: "after", value: move.afterPlayQueueItemID)]
+ }
+ }
+
+ private static func isValidIdentifier(_ identifier: String) -> Bool {
+ guard let identifier = identifier.nilIfBlank else {
+ return false
+ }
+ return identifier.utf8.allSatisfy { (48...57).contains($0) }
+ }
+}
diff --git a/PlexBar/Playback/PlexNativeMediaFacts.swift b/PlexBar/Playback/PlexNativeMediaFacts.swift
new file mode 100644
index 0000000..041ff57
--- /dev/null
+++ b/PlexBar/Playback/PlexNativeMediaFacts.swift
@@ -0,0 +1,570 @@
+import AVFoundation
+import AudioToolbox
+import CoreMedia
+import Foundation
+
+enum PlexVideoDynamicRange: String, Equatable, Sendable {
+ case dolbyVision = "Dolby Vision"
+ case hdr10 = "HDR10"
+ case hdrPQ = "HDR (PQ)"
+ case hlg = "HLG"
+ case sdr = "SDR"
+}
+
+enum PlexDeliveredAudioLayout: String, Equatable, Sendable {
+ case surround5_1 = "5.1"
+ case surround6_1 = "6.1"
+ case surround7_1 = "7.1"
+ case atmos5_1_2 = "Atmos 5.1.2"
+ case atmos5_1_4 = "Atmos 5.1.4"
+ case atmos7_1_2 = "Atmos 7.1.2"
+ case atmos7_1_4 = "Atmos 7.1.4"
+ case atmos9_1_6 = "Atmos 9.1.6"
+}
+
+struct PlexNativeMediaDiagnosticFact: Equatable, Identifiable, Sendable {
+ enum Kind: String, Sendable {
+ case resolution
+ case frameRate
+ case videoCodec
+ case dynamicRange
+ case videoBitRate
+ case audioCodec
+ case channels
+ case sampleRate
+ case audioBitRate
+ }
+
+ let kind: Kind
+ let label: String
+ let value: String
+
+ var id: Kind { kind }
+}
+
+struct PlexNativeMediaFacts: Equatable, Sendable {
+ let videoWidth: Int?
+ let videoHeight: Int?
+ let videoFrameRate: Double?
+ let videoCodec: String?
+ let dynamicRange: PlexVideoDynamicRange?
+ let videoBitRate: Double?
+ let audioCodec: String?
+ let audioChannelCount: Int?
+ let audioLayout: PlexDeliveredAudioLayout?
+ let audioSampleRate: Double?
+ let audioBitRate: Double?
+
+ var compactDisplayComponents: [String] {
+ var components: [String] = []
+
+ if let videoWidth, let videoHeight {
+ components.append("\(videoWidth) × \(videoHeight)")
+ }
+ if let videoCodec {
+ components.append(videoCodec)
+ }
+ if let dynamicRange {
+ components.append(dynamicRange.rawValue)
+ }
+ if let audioCodec {
+ components.append(
+ [audioCodec, audioLayoutLabel].compactMap { $0 }.joined(separator: " ")
+ )
+ } else if let audioLayoutLabel {
+ components.append(audioLayoutLabel)
+ }
+
+ return components
+ }
+
+ var diagnosticFacts: [PlexNativeMediaDiagnosticFact] {
+ var facts: [PlexNativeMediaDiagnosticFact] = []
+
+ if let videoWidth, let videoHeight {
+ facts.append(.init(
+ kind: .resolution,
+ label: "Resolution",
+ value: "\(videoWidth) × \(videoHeight)"
+ ))
+ }
+ if let videoFrameRateLabel {
+ facts.append(.init(kind: .frameRate, label: "Frame Rate", value: videoFrameRateLabel))
+ }
+ if let videoCodec {
+ facts.append(.init(kind: .videoCodec, label: "Video Codec", value: videoCodec))
+ }
+ if let dynamicRange {
+ facts.append(.init(
+ kind: .dynamicRange,
+ label: "Dynamic Range",
+ value: dynamicRange.rawValue
+ ))
+ }
+ if let videoBitRateValue {
+ facts.append(.init(
+ kind: .videoBitRate,
+ label: "Video Data Rate",
+ value: videoBitRateValue
+ ))
+ }
+ if let audioCodec {
+ facts.append(.init(kind: .audioCodec, label: "Audio Codec", value: audioCodec))
+ }
+ if let audioLayoutLabel {
+ facts.append(.init(kind: .channels, label: "Channels", value: audioLayoutLabel))
+ }
+ if let audioSampleRateLabel {
+ facts.append(.init(
+ kind: .sampleRate,
+ label: "Sample Rate",
+ value: audioSampleRateLabel
+ ))
+ }
+ if let audioBitRateValue {
+ facts.append(.init(
+ kind: .audioBitRate,
+ label: "Audio Data Rate",
+ value: audioBitRateValue
+ ))
+ }
+
+ return facts
+ }
+
+ var videoDiagnosticFacts: [PlexNativeMediaDiagnosticFact] {
+ diagnosticFacts.filter { fact in
+ switch fact.kind {
+ case .resolution, .frameRate, .videoCodec, .dynamicRange, .videoBitRate:
+ true
+ case .audioCodec, .channels, .sampleRate, .audioBitRate:
+ false
+ }
+ }
+ }
+
+ var audioDiagnosticFacts: [PlexNativeMediaDiagnosticFact] {
+ diagnosticFacts.filter { fact in
+ switch fact.kind {
+ case .audioCodec, .channels, .sampleRate, .audioBitRate:
+ true
+ case .resolution, .frameRate, .videoCodec, .dynamicRange, .videoBitRate:
+ false
+ }
+ }
+ }
+
+ var displayComponents: [String] {
+ var components: [String] = []
+
+ if let videoWidth, let videoHeight {
+ components.append("\(videoWidth) × \(videoHeight)")
+ }
+ if let videoFrameRateLabel {
+ components.append(videoFrameRateLabel)
+ }
+ if let videoCodec {
+ components.append(videoCodec)
+ }
+ if let dynamicRange {
+ components.append(dynamicRange.rawValue)
+ }
+ if let videoBitRateLabel {
+ components.append(videoBitRateLabel)
+ }
+ if let audioCodec {
+ components.append(
+ [audioCodec, audioLayoutLabel].compactMap { $0 }.joined(separator: " ")
+ )
+ } else if let audioLayoutLabel {
+ components.append(audioLayoutLabel)
+ }
+ if let audioSampleRateLabel {
+ components.append(audioSampleRateLabel)
+ }
+ if let audioBitRateLabel {
+ components.append(audioBitRateLabel)
+ }
+
+ return components
+ }
+
+ private var videoFrameRateLabel: String? {
+ guard let videoFrameRate = Self.positiveFinite(videoFrameRate) else {
+ return nil
+ }
+ return "\(Self.formatted(videoFrameRate, maximumFractionDigits: 3)) fps"
+ }
+
+ private var videoBitRateLabel: String? {
+ videoBitRateValue.map { "\($0) video" }
+ }
+
+ private var videoBitRateValue: String? {
+ Self.bitRateValue(videoBitRate)
+ }
+
+ private var audioLayoutLabel: String? {
+ if let audioLayout {
+ return audioLayout.rawValue
+ }
+ guard let audioChannelCount, audioChannelCount > 0 else {
+ return nil
+ }
+
+ return switch audioChannelCount {
+ case 1: "Mono"
+ case 2: "Stereo"
+ default: "\(audioChannelCount) ch"
+ }
+ }
+
+ private var audioSampleRateLabel: String? {
+ guard let audioSampleRate = Self.positiveFinite(audioSampleRate) else {
+ return nil
+ }
+ if audioSampleRate >= 1_000 {
+ let sampleRate = Self.formatted(
+ audioSampleRate / 1_000,
+ maximumFractionDigits: 3
+ )
+ return "\(sampleRate) kHz"
+ }
+ return "\(Self.formatted(audioSampleRate, maximumFractionDigits: 0)) Hz"
+ }
+
+ private var audioBitRateLabel: String? {
+ audioBitRateValue.map { "\($0) audio" }
+ }
+
+ private var audioBitRateValue: String? {
+ Self.bitRateValue(audioBitRate)
+ }
+
+ private static func bitRateValue(_ bitsPerSecond: Double?) -> String? {
+ guard let bitsPerSecond = positiveFinite(bitsPerSecond) else {
+ return nil
+ }
+ if bitsPerSecond >= 1_000_000 {
+ let bitRate = formatted(
+ bitsPerSecond / 1_000_000,
+ maximumFractionDigits: 3
+ )
+ return "\(bitRate) Mbps"
+ }
+ if bitsPerSecond >= 1_000 {
+ let bitRate = formatted(
+ bitsPerSecond / 1_000,
+ maximumFractionDigits: 3
+ )
+ return "\(bitRate) kbps"
+ }
+ return "\(formatted(bitsPerSecond, maximumFractionDigits: 0)) bps"
+ }
+
+ private static func positiveFinite(_ value: Double?) -> Double? {
+ guard let value, value.isFinite, value > 0 else {
+ return nil
+ }
+ return value
+ }
+
+ private static func formatted(_ value: Double, maximumFractionDigits: Int) -> String {
+ value.formatted(
+ .number
+ .locale(Locale(identifier: "en_US_POSIX"))
+ .precision(.fractionLength(0...maximumFractionDigits))
+ )
+ }
+}
+
+enum PlexNativeMediaInspector {
+ @MainActor
+ static func mediaSelectionAvailability(
+ asset: AVAsset
+ ) async throws -> PlexNativeMediaSelectionAvailability {
+ let audioGroup = try await asset.loadMediaSelectionGroup(for: .audible)
+ let subtitleGroup = try await asset.loadMediaSelectionGroup(for: .legible)
+ return PlexNativeMediaSelectionAvailability(
+ audioOptionCount: audioGroup?.options.count ?? 0,
+ subtitleOptionCount: subtitleGroup?.options.count ?? 0
+ )
+ }
+
+ @MainActor
+ static func inspect(item: AVPlayerItem) async -> PlexNativeMediaFacts? {
+ var videoDescription: CMFormatDescription?
+ var audioDescription: CMFormatDescription?
+ var videoTrack: AVAssetTrack?
+ var audioTrack: AVAssetTrack?
+
+ for itemTrack in item.tracks where itemTrack.isEnabled {
+ guard let assetTrack = itemTrack.assetTrack,
+ let descriptions = try? await assetTrack.load(.formatDescriptions) else {
+ continue
+ }
+
+ for description in descriptions {
+ switch CMFormatDescriptionGetMediaType(description) {
+ case kCMMediaType_Video where videoDescription == nil:
+ videoDescription = description
+ videoTrack = assetTrack
+ case kCMMediaType_Audio where audioDescription == nil:
+ audioDescription = description
+ audioTrack = assetTrack
+ default:
+ break
+ }
+ }
+ }
+
+ var videoFrameRate: Float?
+ var videoBitRate: Float?
+ if let videoTrack {
+ videoFrameRate = try? await videoTrack.load(.nominalFrameRate)
+ videoBitRate = try? await videoTrack.load(.estimatedDataRate)
+ }
+ var audioBitRate: Float?
+ if let audioTrack {
+ audioBitRate = try? await audioTrack.load(.estimatedDataRate)
+ }
+
+ return facts(
+ videoFormatDescription: videoDescription,
+ audioFormatDescription: audioDescription,
+ videoFrameRate: videoFrameRate.map(Double.init),
+ videoBitRate: videoBitRate.map(Double.init),
+ audioBitRate: audioBitRate.map(Double.init)
+ )
+ }
+
+ static func facts(
+ videoFormatDescription: CMFormatDescription?,
+ audioFormatDescription: CMFormatDescription?,
+ videoFrameRate: Double? = nil,
+ videoBitRate: Double? = nil,
+ audioBitRate: Double? = nil
+ ) -> PlexNativeMediaFacts? {
+ let videoSubtype = videoFormatDescription.map(CMFormatDescriptionGetMediaSubType)
+ let dimensions = videoFormatDescription.map(CMVideoFormatDescriptionGetDimensions)
+ let audioStreamDescription = audioFormatDescription.flatMap {
+ CMAudioFormatDescriptionGetStreamBasicDescription($0)?.pointee
+ }
+ let audioFormatID = audioStreamDescription?.mFormatID
+ ?? audioFormatDescription.map(CMFormatDescriptionGetMediaSubType)
+ let audioLayoutTag = audioFormatDescription.flatMap {
+ CMAudioFormatDescriptionGetChannelLayout($0, sizeOut: nil)?.pointee.mChannelLayoutTag
+ }
+
+ let facts = PlexNativeMediaFacts(
+ videoWidth: positiveInt(dimensions?.width),
+ videoHeight: positiveInt(dimensions?.height),
+ videoFrameRate: positiveFinite(videoFrameRate),
+ videoCodec: videoSubtype.flatMap(videoCodecName),
+ dynamicRange: videoFormatDescription.flatMap(dynamicRange),
+ videoBitRate: positiveFinite(videoBitRate),
+ audioCodec: audioFormatID.flatMap(audioCodecName),
+ audioChannelCount: positiveInt(audioStreamDescription?.mChannelsPerFrame),
+ audioLayout: audioLayoutTag.flatMap(audioLayout),
+ audioSampleRate: positiveFinite(audioStreamDescription?.mSampleRate),
+ audioBitRate: positiveFinite(audioBitRate)
+ )
+
+ return facts.displayComponents.isEmpty ? nil : facts
+ }
+
+ static func videoCodecName(for mediaSubtype: FourCharCode) -> String? {
+ switch mediaSubtype {
+ case kCMVideoCodecType_H264, fourCC("avc3"), fourCC("dva1"), fourCC("dvav"):
+ "H.264"
+ case kCMVideoCodecType_HEVC,
+ kCMVideoCodecType_HEVCWithAlpha,
+ kCMVideoCodecType_DolbyVisionHEVC,
+ fourCC("hev1"),
+ fourCC("dvhe"):
+ "HEVC"
+ case kCMVideoCodecType_AV1:
+ "AV1"
+ case kCMVideoCodecType_VP9:
+ "VP9"
+ case kCMVideoCodecType_MPEG4Video:
+ "MPEG-4 Video"
+ case kCMVideoCodecType_AppleProRes4444XQ,
+ kCMVideoCodecType_AppleProRes4444,
+ kCMVideoCodecType_AppleProRes422HQ,
+ kCMVideoCodecType_AppleProRes422,
+ kCMVideoCodecType_AppleProRes422LT,
+ kCMVideoCodecType_AppleProRes422Proxy:
+ "Apple ProRes"
+ default:
+ nil
+ }
+ }
+
+ static func audioCodecName(for formatID: AudioFormatID) -> String? {
+ switch formatID {
+ case kAudioFormatMPEG4AAC,
+ kAudioFormatMPEG4AAC_HE,
+ kAudioFormatMPEG4AAC_HE_V2,
+ kAudioFormatMPEG4AAC_LD,
+ kAudioFormatMPEG4AAC_ELD,
+ kAudioFormatMPEG4AAC_ELD_SBR,
+ kAudioFormatMPEG4AAC_ELD_V2,
+ kAudioFormatMPEG4AAC_Spatial,
+ fourCC("mp4a"):
+ "AAC"
+ case kAudioFormatAC3:
+ "AC-3"
+ case kAudioFormatEnhancedAC3:
+ "E-AC-3"
+ case kAudioFormatAppleLossless:
+ "ALAC"
+ case kAudioFormatFLAC:
+ "FLAC"
+ case kAudioFormatOpus:
+ "Opus"
+ case kAudioFormatMPEGLayer3:
+ "MP3"
+ case kAudioFormatLinearPCM:
+ "PCM"
+ default:
+ nil
+ }
+ }
+
+ static func audioLayout(for tag: AudioChannelLayoutTag) -> PlexDeliveredAudioLayout? {
+ switch tag {
+ case kAudioChannelLayoutTag_MPEG_5_1_A,
+ kAudioChannelLayoutTag_MPEG_5_1_B,
+ kAudioChannelLayoutTag_MPEG_5_1_C,
+ kAudioChannelLayoutTag_MPEG_5_1_D,
+ kAudioChannelLayoutTag_MPEG_5_1_E,
+ kAudioChannelLayoutTag_WAVE_5_1_B:
+ .surround5_1
+ case kAudioChannelLayoutTag_MPEG_6_1_A,
+ kAudioChannelLayoutTag_MPEG_6_1_B,
+ kAudioChannelLayoutTag_AAC_6_1,
+ kAudioChannelLayoutTag_EAC3_6_1_A,
+ kAudioChannelLayoutTag_EAC3_6_1_B,
+ kAudioChannelLayoutTag_EAC3_6_1_C,
+ kAudioChannelLayoutTag_DTS_6_1_A,
+ kAudioChannelLayoutTag_DTS_6_1_B,
+ kAudioChannelLayoutTag_DTS_6_1_C,
+ kAudioChannelLayoutTag_DTS_6_1_D,
+ kAudioChannelLayoutTag_WAVE_6_1:
+ .surround6_1
+ case kAudioChannelLayoutTag_MPEG_7_1_A,
+ kAudioChannelLayoutTag_MPEG_7_1_B,
+ kAudioChannelLayoutTag_MPEG_7_1_C,
+ kAudioChannelLayoutTag_MPEG_7_1_D,
+ kAudioChannelLayoutTag_AAC_7_1_B,
+ kAudioChannelLayoutTag_AAC_7_1_C,
+ kAudioChannelLayoutTag_EAC3_7_1_A,
+ kAudioChannelLayoutTag_EAC3_7_1_B,
+ kAudioChannelLayoutTag_EAC3_7_1_C,
+ kAudioChannelLayoutTag_EAC3_7_1_D,
+ kAudioChannelLayoutTag_EAC3_7_1_E,
+ kAudioChannelLayoutTag_EAC3_7_1_F,
+ kAudioChannelLayoutTag_EAC3_7_1_G,
+ kAudioChannelLayoutTag_EAC3_7_1_H,
+ kAudioChannelLayoutTag_DTS_7_1,
+ kAudioChannelLayoutTag_WAVE_7_1:
+ .surround7_1
+ case kAudioChannelLayoutTag_Atmos_5_1_2:
+ .atmos5_1_2
+ case kAudioChannelLayoutTag_Atmos_5_1_4:
+ .atmos5_1_4
+ case kAudioChannelLayoutTag_Atmos_7_1_2:
+ .atmos7_1_2
+ case kAudioChannelLayoutTag_Atmos_7_1_4:
+ .atmos7_1_4
+ case kAudioChannelLayoutTag_Atmos_9_1_6:
+ .atmos9_1_6
+ default:
+ nil
+ }
+ }
+
+ static func dynamicRange(
+ mediaSubtype: FourCharCode,
+ transferFunction: CFString?,
+ hasHDR10StaticMetadata: Bool
+ ) -> PlexVideoDynamicRange? {
+ if [
+ kCMVideoCodecType_DolbyVisionHEVC,
+ fourCC("dvhe"),
+ fourCC("dva1"),
+ fourCC("dvav"),
+ ].contains(mediaSubtype) {
+ return .dolbyVision
+ }
+
+ guard let transferFunction else {
+ return nil
+ }
+
+ if CFEqual(transferFunction, kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ) {
+ return hasHDR10StaticMetadata ? .hdr10 : .hdrPQ
+ }
+ if CFEqual(transferFunction, kCMFormatDescriptionTransferFunction_ITU_R_2100_HLG) {
+ return .hlg
+ }
+ if CFEqual(transferFunction, kCMFormatDescriptionTransferFunction_ITU_R_709_2)
+ || CFEqual(transferFunction, kCMFormatDescriptionTransferFunction_ITU_R_2020)
+ || CFEqual(transferFunction, kCMFormatDescriptionTransferFunction_sRGB) {
+ return .sdr
+ }
+
+ return nil
+ }
+
+ private static func dynamicRange(
+ for description: CMFormatDescription
+ ) -> PlexVideoDynamicRange? {
+ let subtype = CMFormatDescriptionGetMediaSubType(description)
+ let transferFunction: CFString?
+ if let value = CMFormatDescriptionGetExtension(
+ description,
+ extensionKey: kCMFormatDescriptionExtension_TransferFunction
+ ), CFGetTypeID(value) == CFStringGetTypeID() {
+ transferFunction = (value as! CFString)
+ } else {
+ transferFunction = nil
+ }
+ let hasHDR10StaticMetadata = CMFormatDescriptionGetExtension(
+ description,
+ extensionKey: kCMFormatDescriptionExtension_MasteringDisplayColorVolume
+ ) != nil || CMFormatDescriptionGetExtension(
+ description,
+ extensionKey: kCMFormatDescriptionExtension_ContentLightLevelInfo
+ ) != nil
+
+ return dynamicRange(
+ mediaSubtype: subtype,
+ transferFunction: transferFunction,
+ hasHDR10StaticMetadata: hasHDR10StaticMetadata
+ )
+ }
+
+ private static func positiveInt(_ value: T?) -> Int? {
+ guard let value, value > 0 else {
+ return nil
+ }
+ return Int(value)
+ }
+
+ private static func positiveFinite(_ value: Double?) -> Double? {
+ guard let value, value.isFinite, value > 0 else {
+ return nil
+ }
+ return value
+ }
+
+ private static func fourCC(_ string: StaticString) -> FourCharCode {
+ let bytes = string.withUTF8Buffer { Array($0) }
+ precondition(bytes.count == 4)
+ return bytes.reduce(0) { partialResult, byte in
+ (partialResult << 8) | FourCharCode(byte)
+ }
+ }
+}
diff --git a/PlexBar/Playback/PlexNativePlaybackSpeedConfiguration.swift b/PlexBar/Playback/PlexNativePlaybackSpeedConfiguration.swift
new file mode 100644
index 0000000..0ca8aae
--- /dev/null
+++ b/PlexBar/Playback/PlexNativePlaybackSpeedConfiguration.swift
@@ -0,0 +1,51 @@
+import AVKit
+
+@MainActor
+enum PlexNativePlaybackSpeedConfiguration {
+ static let speeds = PlexPlaybackRate.allCases.map { playbackRate in
+ AVPlaybackSpeed(
+ rate: playbackRate.rawValue,
+ localizedName: playbackRate.label
+ )
+ }
+
+ #if os(macOS)
+ static func apply(
+ to playerView: AVPlayerView,
+ playbackRate: PlexPlaybackRate
+ ) {
+ applySelectableSpeeds(to: playerView)
+
+ guard let speed = speed(for: playbackRate),
+ playerView.selectedSpeed?.rate != speed.rate else {
+ return
+ }
+ playerView.selectSpeed(speed)
+ }
+
+ private static func applySelectableSpeeds(to playerView: AVPlayerView) {
+ guard playerView.speeds.map(\.rate) != speeds.map(\.rate) else { return }
+ playerView.speeds = speeds
+ }
+ #endif
+
+ #if os(tvOS)
+ static func apply(to playerViewController: AVPlayerViewController) {
+ guard playerViewController.speeds.map(\.rate) != speeds.map(\.rate) else { return }
+ playerViewController.speeds = speeds
+ }
+ #endif
+
+ static func speed(for playbackRate: PlexPlaybackRate) -> AVPlaybackSpeed? {
+ speeds.first { speed in
+ abs(speed.rate - playbackRate.rawValue) < 0.001
+ }
+ }
+
+ static func playbackRate(for speed: AVPlaybackSpeed?) -> PlexPlaybackRate? {
+ guard let speed else {
+ return nil
+ }
+ return PlexPlaybackRate(remoteCommandValue: speed.rate)
+ }
+}
diff --git a/PlexBar/Playback/PlexNativeVideoScalingConfiguration.swift b/PlexBar/Playback/PlexNativeVideoScalingConfiguration.swift
new file mode 100644
index 0000000..02959f7
--- /dev/null
+++ b/PlexBar/Playback/PlexNativeVideoScalingConfiguration.swift
@@ -0,0 +1,36 @@
+import AVFoundation
+import AVKit
+
+@MainActor
+enum PlexNativeVideoScalingConfiguration {
+ #if os(macOS)
+ static func apply(
+ to playerView: AVPlayerView,
+ scalingMode: PlexVideoScalingMode
+ ) {
+ let videoGravity = scalingMode.avVideoGravity
+ guard playerView.videoGravity != videoGravity else { return }
+ playerView.videoGravity = videoGravity
+ }
+ #endif
+
+ #if os(tvOS)
+ static func apply(
+ to playerViewController: AVPlayerViewController,
+ scalingMode: PlexVideoScalingMode
+ ) {
+ let videoGravity = scalingMode.avVideoGravity
+ guard playerViewController.videoGravity != videoGravity else { return }
+ playerViewController.videoGravity = videoGravity
+ }
+ #endif
+}
+
+extension PlexVideoScalingMode {
+ var avVideoGravity: AVLayerVideoGravity {
+ switch self {
+ case .fit: .resizeAspect
+ case .fill: .resizeAspectFill
+ }
+ }
+}
diff --git a/PlexBar/Playback/PlexNowPlayingController.swift b/PlexBar/Playback/PlexNowPlayingController.swift
new file mode 100644
index 0000000..9d6f95a
--- /dev/null
+++ b/PlexBar/Playback/PlexNowPlayingController.swift
@@ -0,0 +1,957 @@
+import PlexModels
+import CoreGraphics
+import Foundation
+@preconcurrency import MediaPlayer
+
+#if os(macOS)
+import AppKit
+#elseif os(tvOS)
+import UIKit
+#endif
+
+#if os(macOS)
+struct PlexNowPlayingArtworkRequest: Hashable, Sendable {
+ static let maximumPixelSize = 1_200
+
+ let candidateURLs: [URL]
+ let token: String
+ let clientContext: PlexClientContext
+
+ init?(
+ item: PlexMediaItem,
+ serverURL: URL?,
+ token: String,
+ clientContext: PlexClientContext
+ ) {
+ guard let serverURL else {
+ return nil
+ }
+ let candidateURLs = item.nowPlayingArtworkPaths.compactMap {
+ PlexURLBuilder.mediaURL(serverURL: serverURL, path: $0)
+ }
+ guard !candidateURLs.isEmpty else {
+ return nil
+ }
+
+ self.candidateURLs = candidateURLs
+ self.token = token
+ self.clientContext = clientContext
+ }
+}
+
+@MainActor
+final class PlexNowPlayingArtworkLoader {
+ typealias FetchImage = @Sendable (PlexNowPlayingArtworkRequest) async -> PlexCGImageBox?
+
+ private let fetchImage: FetchImage
+ private var loadGeneration = 0
+
+ init(imageClient: PlexImageClient = PlexImageClient()) {
+ fetchImage = { request in
+ await imageClient.fetchCGImageResult(
+ from: request.candidateURLs,
+ token: request.token,
+ clientContext: request.clientContext,
+ maximumPixelSize: PlexNowPlayingArtworkRequest.maximumPixelSize
+ ).map { PlexCGImageBox($0.image) }
+ }
+ }
+
+ init(fetchImage: @escaping FetchImage) {
+ self.fetchImage = fetchImage
+ }
+
+ func load(_ request: PlexNowPlayingArtworkRequest) async -> CGImage? {
+ guard !Task.isCancelled else {
+ return nil
+ }
+ loadGeneration &+= 1
+ let generation = loadGeneration
+ guard let image = await fetchImage(request),
+ !Task.isCancelled,
+ generation == loadGeneration else {
+ return nil
+ }
+ return image.image
+ }
+
+ func invalidate() {
+ loadGeneration &+= 1
+ }
+}
+#endif
+
+enum PlexNowPlayingArtworkFactory {
+ static func make(from image: CGImage) -> MPMediaItemArtwork {
+ let imageBox = PlexNowPlayingArtworkImageBox(image)
+ let boundsSize = CGSize(width: image.width, height: image.height)
+ return MPMediaItemArtwork(boundsSize: boundsSize) { requestedSize in
+#if os(macOS)
+ let size = validRequestedSize(requestedSize, boundedBy: boundsSize)
+ return NSImage(cgImage: imageBox.image, size: size)
+#elseif os(tvOS)
+ return UIImage(cgImage: imageBox.image)
+#endif
+ }
+ }
+
+ private static func validRequestedSize(
+ _ requestedSize: CGSize,
+ boundedBy boundsSize: CGSize
+ ) -> CGSize {
+ guard requestedSize.width.isFinite,
+ requestedSize.height.isFinite,
+ requestedSize.width > 0,
+ requestedSize.height > 0 else {
+ return boundsSize
+ }
+ return CGSize(
+ width: min(requestedSize.width, boundsSize.width),
+ height: min(requestedSize.height, boundsSize.height)
+ )
+ }
+}
+
+private final class PlexNowPlayingArtworkImageBox: @unchecked Sendable {
+ let image: CGImage
+
+ init(_ image: CGImage) {
+ self.image = image
+ }
+}
+
+enum PlexNowPlayingLanguageSelection: Equatable, Sendable {
+ case audio(streamID: Int)
+ case subtitle(streamID: Int)
+
+ init?(languageOption: MPNowPlayingInfoLanguageOption) {
+ guard let identifier = languageOption.identifier else {
+ return nil
+ }
+ if identifier.hasPrefix(Self.audioPrefix),
+ languageOption.languageOptionType == .audible,
+ let streamID = Int(identifier.dropFirst(Self.audioPrefix.count)) {
+ self = .audio(streamID: streamID)
+ } else if identifier.hasPrefix(Self.subtitlePrefix),
+ languageOption.languageOptionType == .legible,
+ let streamID = Int(identifier.dropFirst(Self.subtitlePrefix.count)) {
+ self = .subtitle(streamID: streamID)
+ } else {
+ return nil
+ }
+ }
+
+ var identifier: String {
+ switch self {
+ case .audio(let streamID):
+ Self.audioPrefix + String(streamID)
+ case .subtitle(let streamID):
+ Self.subtitlePrefix + String(streamID)
+ }
+ }
+
+ private static let audioPrefix = "plex-audio-stream:"
+ private static let subtitlePrefix = "plex-subtitle-stream:"
+}
+
+struct PlexNowPlayingLanguageOptions {
+ let groups: [MPNowPlayingInfoLanguageOptionGroup]
+ let currentOptions: [MPNowPlayingInfoLanguageOption]
+ let hasSelectedSubtitle: Bool
+
+ init() {
+ self.init(groups: [], currentOptions: [], hasSelectedSubtitle: false)
+ }
+
+ init(
+ selection: PlexPlaybackMediaSelection,
+ nativeAvailability: PlexNativeMediaSelectionAvailability?
+ ) {
+ guard let nativeAvailability else {
+ self.init()
+ return
+ }
+
+ var groups: [MPNowPlayingInfoLanguageOptionGroup] = []
+ var currentOptions: [MPNowPlayingInfoLanguageOption] = []
+ var hasSelectedSubtitle = false
+
+ let serverManagedSelection = PlexServerManagedMediaSelection(
+ selection: selection,
+ nativeAvailability: nativeAvailability
+ )
+
+ if !serverManagedSelection.audioOptions.isEmpty,
+ let audioGroup = Self.group(
+ options: serverManagedSelection.audioOptions,
+ type: .audible,
+ requiresMultipleOptions: true,
+ allowsEmptySelection: false
+ ) {
+ groups.append(audioGroup.group)
+ if let selected = audioGroup.selected {
+ currentOptions.append(selected)
+ }
+ }
+
+ if !serverManagedSelection.subtitleOptions.isEmpty,
+ let subtitleGroup = Self.group(
+ options: serverManagedSelection.subtitleOptions,
+ type: .legible,
+ requiresMultipleOptions: false,
+ allowsEmptySelection: true
+ ) {
+ groups.append(subtitleGroup.group)
+ if let selected = subtitleGroup.selected {
+ currentOptions.append(selected)
+ hasSelectedSubtitle = true
+ }
+ }
+
+ self.init(
+ groups: groups,
+ currentOptions: currentOptions,
+ hasSelectedSubtitle: hasSelectedSubtitle
+ )
+ }
+
+ var hasAvailableOptions: Bool {
+ !groups.isEmpty
+ }
+
+ private static func group(
+ options: [PlexMediaSelectionOption],
+ type: MPNowPlayingInfoLanguageOptionType,
+ requiresMultipleOptions: Bool,
+ allowsEmptySelection: Bool
+ ) -> (
+ group: MPNowPlayingInfoLanguageOptionGroup,
+ selected: MPNowPlayingInfoLanguageOption?
+ )? {
+ let pairs = options.compactMap { option -> (
+ source: PlexMediaSelectionOption,
+ languageOption: MPNowPlayingInfoLanguageOption
+ )? in
+ guard let languageTag = option.languageTag else {
+ return nil
+ }
+ let selection: PlexNowPlayingLanguageSelection
+ switch type {
+ case .audible:
+ selection = .audio(streamID: option.id)
+ case .legible:
+ selection = .subtitle(streamID: option.id)
+ @unknown default:
+ return nil
+ }
+ return (
+ option,
+ MPNowPlayingInfoLanguageOption(
+ type: type,
+ languageTag: languageTag,
+ characteristics: characteristics(for: option, type: type),
+ displayName: option.title,
+ identifier: selection.identifier
+ )
+ )
+ }
+ guard !pairs.isEmpty,
+ !requiresMultipleOptions || pairs.count > 1 else {
+ return nil
+ }
+
+ let selected = pairs.first(where: { $0.source.isSelected })?.languageOption
+ guard allowsEmptySelection || selected != nil else {
+ return nil
+ }
+ return (
+ MPNowPlayingInfoLanguageOptionGroup(
+ languageOptions: pairs.map(\.languageOption),
+ defaultLanguageOption: selected,
+ allowEmptySelection: allowsEmptySelection
+ ),
+ selected
+ )
+ }
+
+ private static func characteristics(
+ for option: PlexMediaSelectionOption,
+ type: MPNowPlayingInfoLanguageOptionType
+ ) -> [String] {
+ switch type {
+ case .audible:
+ if option.isVisualImpaired {
+ return [MPLanguageOptionCharacteristicDescribesVideo]
+ }
+ return [MPLanguageOptionCharacteristicIsMainProgramContent]
+ case .legible:
+ var characteristics = [MPLanguageOptionCharacteristicTranscribesSpokenDialog]
+ if option.isForced {
+ characteristics.append(MPLanguageOptionCharacteristicContainsOnlyForcedSubtitles)
+ }
+ if option.isHearingImpaired {
+ characteristics.append(MPLanguageOptionCharacteristicDescribesMusicAndSound)
+ }
+ return characteristics
+ @unknown default:
+ return []
+ }
+ }
+
+ private init(
+ groups: [MPNowPlayingInfoLanguageOptionGroup],
+ currentOptions: [MPNowPlayingInfoLanguageOption],
+ hasSelectedSubtitle: Bool
+ ) {
+ self.groups = groups
+ self.currentOptions = currentOptions
+ self.hasSelectedSubtitle = hasSelectedSubtitle
+ }
+}
+
+struct PlexNowPlayingMetadata: Equatable, Sendable {
+ struct PublicationFingerprint: Equatable, Sendable {
+ let title: String
+ let artist: String?
+ let albumTitle: String?
+ let genre: String?
+ let mediaKind: MediaKind
+ let duration: TimeInterval?
+ let creditsStartTime: TimeInterval?
+ let defaultPlaybackRate: Double
+ let itemRatingKey: String
+ let externalContentIdentifier: String?
+ let collectionIdentifier: String?
+ let albumTrackNumber: Int?
+ let discNumber: Int?
+ let queueIndex: Int?
+ let queueCount: Int?
+ }
+
+ enum MediaKind: Equatable, Sendable {
+ case audio
+ case movie
+ case television
+ case video
+ }
+
+ let title: String
+ let artist: String?
+ let albumTitle: String?
+ let genre: String?
+ let mediaKind: MediaKind
+ let duration: TimeInterval?
+ let creditsStartTime: TimeInterval?
+ let elapsedTime: TimeInterval
+ let playbackRate: Double
+ let defaultPlaybackRate: Double
+ let itemRatingKey: String
+ let externalContentIdentifier: String?
+ let collectionIdentifier: String?
+ let albumTrackNumber: Int?
+ let discNumber: Int?
+ let queueIndex: Int?
+ let queueCount: Int?
+
+ var publicationFingerprint: PublicationFingerprint {
+ PublicationFingerprint(
+ title: title,
+ artist: artist,
+ albumTitle: albumTitle,
+ genre: genre,
+ mediaKind: mediaKind,
+ duration: duration,
+ creditsStartTime: creditsStartTime,
+ defaultPlaybackRate: defaultPlaybackRate,
+ itemRatingKey: itemRatingKey,
+ externalContentIdentifier: externalContentIdentifier,
+ collectionIdentifier: collectionIdentifier,
+ albumTrackNumber: albumTrackNumber,
+ discNumber: discNumber,
+ queueIndex: queueIndex,
+ queueCount: queueCount
+ )
+ }
+
+ init(
+ item: PlexMediaItem,
+ duration: TimeInterval?,
+ elapsedTime: TimeInterval,
+ playbackRate: Double,
+ defaultPlaybackRate: Double = 1,
+ serverIdentifier: String? = nil,
+ queuePosition: Int? = nil,
+ queueCount: Int? = nil
+ ) {
+ let presentation = PlexPlayerPlaybackInfoPresentation(item: item)
+ title = presentation.title
+ artist = item.grandparentTitle?.nilIfBlank ?? item.originalTitle?.nilIfBlank
+ albumTitle = item.parentTitle?.nilIfBlank
+ genre = presentation.genre
+ mediaKind = Self.mediaKind(for: item.type)
+ self.duration = duration.flatMap { $0.isFinite && $0 > 0 ? $0 : nil }
+ creditsStartTime = PlexPlaybackMarkerAction.creditsStartTime(
+ in: item.markers,
+ duration: self.duration
+ )
+ self.elapsedTime = elapsedTime.isFinite ? max(elapsedTime, 0) : 0
+ self.playbackRate = playbackRate.isFinite ? max(playbackRate, 0) : 0
+ self.defaultPlaybackRate = defaultPlaybackRate.isFinite && defaultPlaybackRate > 0
+ ? defaultPlaybackRate
+ : 1
+ itemRatingKey = item.ratingKey
+ externalContentIdentifier = Self.scopedIdentifier(
+ serverIdentifier: serverIdentifier,
+ ratingKey: item.ratingKey
+ )
+ collectionIdentifier = item.nowPlayingCollectionRatingKey.flatMap {
+ Self.scopedIdentifier(serverIdentifier: serverIdentifier, ratingKey: $0)
+ }
+
+ if mediaKind == .audio {
+ albumTrackNumber = item.index.flatMap { $0 > 0 ? $0 : nil }
+ discNumber = item.parentIndex.flatMap { $0 > 0 ? $0 : nil }
+ } else {
+ albumTrackNumber = nil
+ discNumber = nil
+ }
+
+ if let queuePosition,
+ let queueCount,
+ queuePosition > 0,
+ queuePosition <= queueCount {
+ queueIndex = queuePosition - 1
+ self.queueCount = queueCount
+ } else {
+ queueIndex = nil
+ self.queueCount = nil
+ }
+ }
+
+ private static func mediaKind(for type: String?) -> MediaKind {
+ switch type?.lowercased() {
+ case "track":
+ .audio
+ case "movie":
+ .movie
+ case "episode":
+ .television
+ default:
+ .video
+ }
+ }
+
+ private static func scopedIdentifier(
+ serverIdentifier: String?,
+ ratingKey: String
+ ) -> String? {
+ guard let serverIdentifier = serverIdentifier?.nilIfBlank else {
+ return nil
+ }
+
+ return "plex:\(serverIdentifier.utf8.count):\(serverIdentifier):\(ratingKey.utf8.count):\(ratingKey)"
+ }
+
+ var nowPlayingInfo: [String: Any] {
+ nowPlayingInfo(artwork: nil)
+ }
+
+ func nowPlayingInfo(
+ artwork: MPMediaItemArtwork?,
+ languageOptions: PlexNowPlayingLanguageOptions? = nil
+ ) -> [String: Any] {
+ var info: [String: Any] = [
+ MPMediaItemPropertyTitle: title,
+ MPMediaItemPropertyMediaType: NSNumber(value: mediaType.rawValue),
+ MPNowPlayingInfoPropertyMediaType: NSNumber(value: nowPlayingMediaType.rawValue),
+ MPNowPlayingInfoPropertyElapsedPlaybackTime: NSNumber(value: elapsedTime),
+ MPNowPlayingInfoPropertyPlaybackRate: NSNumber(value: playbackRate),
+ MPNowPlayingInfoPropertyDefaultPlaybackRate: NSNumber(value: defaultPlaybackRate),
+ MPNowPlayingInfoPropertyExcludeFromSuggestions: true
+ ]
+
+ if let artist {
+ info[MPMediaItemPropertyArtist] = artist
+ }
+ if let albumTitle {
+ info[MPMediaItemPropertyAlbumTitle] = albumTitle
+ }
+ if let genre {
+ info[MPMediaItemPropertyGenre] = genre
+ }
+ if let duration {
+ info[MPMediaItemPropertyPlaybackDuration] = NSNumber(value: duration)
+ }
+ if let creditsStartTime {
+ info[MPNowPlayingInfoPropertyCreditsStartTime] = NSNumber(value: creditsStartTime)
+ }
+ if let externalContentIdentifier {
+ info[MPNowPlayingInfoPropertyExternalContentIdentifier] = externalContentIdentifier
+ }
+ if let collectionIdentifier {
+ info[MPNowPlayingInfoCollectionIdentifier] = collectionIdentifier
+ }
+ if let albumTrackNumber {
+ info[MPMediaItemPropertyAlbumTrackNumber] = NSNumber(value: albumTrackNumber)
+ }
+ if let discNumber {
+ info[MPMediaItemPropertyDiscNumber] = NSNumber(value: discNumber)
+ }
+ if let queueIndex, let queueCount {
+ info[MPNowPlayingInfoPropertyPlaybackQueueIndex] = NSNumber(value: queueIndex)
+ info[MPNowPlayingInfoPropertyPlaybackQueueCount] = NSNumber(value: queueCount)
+ }
+ if let artwork {
+ info[MPMediaItemPropertyArtwork] = artwork
+ }
+ if let languageOptions, languageOptions.hasAvailableOptions {
+ info[MPNowPlayingInfoPropertyAvailableLanguageOptions] = languageOptions.groups
+ info[MPNowPlayingInfoPropertyCurrentLanguageOptions] = languageOptions.currentOptions
+ }
+
+ return info
+ }
+
+ private var mediaType: MPMediaType {
+ switch mediaKind {
+ case .audio:
+ .anyAudio
+ case .movie:
+ .movie
+ case .television:
+ .tvShow
+ case .video:
+ .anyVideo
+ }
+ }
+
+ private var nowPlayingMediaType: MPNowPlayingInfoMediaType {
+ switch mediaKind {
+ case .audio:
+ .audio
+ case .movie, .television, .video:
+ .video
+ }
+ }
+}
+
+#if os(macOS)
+struct PlexRemotePlaybackActions: Sendable {
+ let play: @MainActor @Sendable () -> Void
+ let pause: @MainActor @Sendable () -> Void
+ let togglePlayPause: @MainActor @Sendable () -> Void
+ let stop: @MainActor @Sendable () -> Void
+ let seek: @MainActor @Sendable (TimeInterval) -> Void
+ let skip: @MainActor @Sendable (TimeInterval) -> Void
+ let selectAudioStream: @MainActor @Sendable (Int) -> Void
+ let selectSubtitleStream: @MainActor @Sendable (Int?) -> Void
+ let changePlaybackRate: @MainActor @Sendable (PlexPlaybackRate) -> Void
+ let changeShuffle: @MainActor @Sendable (Bool) -> Void
+ let changeRepeatMode: @MainActor @Sendable (PlexPlaybackRepeatMode) -> Void
+ let previous: @MainActor @Sendable () -> Void
+ let next: @MainActor @Sendable () -> Void
+}
+
+@MainActor
+final class PlexNowPlayingController {
+ private let infoCenter: MPNowPlayingInfoCenter
+ private let commandCenter: MPRemoteCommandCenter
+ private var commandTargets: [(command: MPRemoteCommand, target: Any)] = []
+ private var artwork: MPMediaItemArtwork?
+ private var languageOptions = PlexNowPlayingLanguageOptions()
+ private var canChangeLanguageOptions = false
+ private var lastMetadata: PlexNowPlayingMetadata?
+ private var lastStatus: PlexPlaybackStatus?
+
+ init(
+ infoCenter: MPNowPlayingInfoCenter = .default(),
+ commandCenter: MPRemoteCommandCenter = .shared()
+ ) {
+ self.infoCenter = infoCenter
+ self.commandCenter = commandCenter
+ }
+
+ func activate(
+ metadata: PlexNowPlayingMetadata,
+ status: PlexPlaybackStatus,
+ actions: PlexRemotePlaybackActions,
+ canGoPrevious: Bool,
+ canGoNext: Bool,
+ canSeek: Bool,
+ languageOptions: PlexNowPlayingLanguageOptions,
+ canChangeLanguageOptions: Bool,
+ canChangeShuffle: Bool,
+ isShuffled: Bool,
+ repeatMode: PlexPlaybackRepeatMode,
+ canRepeatAll: Bool
+ ) {
+ deactivate()
+ self.languageOptions = languageOptions
+ self.canChangeLanguageOptions = canChangeLanguageOptions
+ registerCommands(actions: actions, canRepeatAll: canRepeatAll)
+ updateNavigation(canGoPrevious: canGoPrevious, canGoNext: canGoNext)
+ updateSeeking(canSeek: canSeek)
+ updateLanguageCommandAvailability()
+ updateShuffle(canChange: canChangeShuffle, isShuffled: isShuffled)
+ updateRepeatMode(repeatMode)
+ publish(metadata: metadata, status: status)
+ }
+
+ func updateNavigation(canGoPrevious: Bool, canGoNext: Bool) {
+ commandCenter.previousTrackCommand.isEnabled = canGoPrevious
+ commandCenter.nextTrackCommand.isEnabled = canGoNext
+ }
+
+ func updateSeeking(canSeek: Bool) {
+ commandCenter.changePlaybackPositionCommand.isEnabled = canSeek
+ commandCenter.skipBackwardCommand.isEnabled = canSeek
+ commandCenter.skipForwardCommand.isEnabled = canSeek
+ }
+
+ func updateLanguageOptions(_ languageOptions: PlexNowPlayingLanguageOptions) {
+ self.languageOptions = languageOptions
+ updateLanguageCommandAvailability()
+ guard let lastMetadata, let lastStatus else {
+ return
+ }
+ publish(metadata: lastMetadata, status: lastStatus)
+ }
+
+ func updateLanguageCommandAvailability(canChange: Bool) {
+ canChangeLanguageOptions = canChange
+ applyLanguageCommandAvailability()
+ }
+
+ func updateShuffle(canChange: Bool, isShuffled: Bool) {
+ commandCenter.changeShuffleModeCommand.currentShuffleType = isShuffled ? .items : .off
+ commandCenter.changeShuffleModeCommand.isEnabled = canChange
+ }
+
+ func updateRepeatMode(_ repeatMode: PlexPlaybackRepeatMode) {
+ commandCenter.changeRepeatModeCommand.currentRepeatType = repeatMode.remoteCommandValue
+ commandCenter.changeRepeatModeCommand.isEnabled = true
+ }
+
+ func updateArtwork(
+ _ image: CGImage,
+ itemRatingKey: String
+ ) {
+ guard let lastMetadata,
+ lastMetadata.itemRatingKey == itemRatingKey,
+ let lastStatus else {
+ return
+ }
+ artwork = PlexNowPlayingArtworkFactory.make(from: image)
+ publish(metadata: lastMetadata, status: lastStatus)
+ }
+
+ func update(
+ metadata: PlexNowPlayingMetadata,
+ status: PlexPlaybackStatus,
+ force: Bool = false
+ ) {
+ guard force
+ || status != lastStatus
+ || metadata.publicationFingerprint != lastMetadata?.publicationFingerprint else {
+ return
+ }
+
+ publish(metadata: metadata, status: status)
+ }
+
+ func deactivate() {
+ for commandTarget in commandTargets {
+ commandTarget.command.removeTarget(commandTarget.target)
+ }
+ commandTargets.removeAll()
+ setOwnedCommandsEnabled(false)
+ infoCenter.nowPlayingInfo = nil
+ infoCenter.playbackState = .stopped
+ artwork = nil
+ languageOptions = PlexNowPlayingLanguageOptions()
+ canChangeLanguageOptions = false
+ lastMetadata = nil
+ lastStatus = nil
+ }
+
+ private func registerCommands(
+ actions: PlexRemotePlaybackActions,
+ canRepeatAll: Bool
+ ) {
+ setOwnedCommandsEnabled(true)
+ addTarget(to: commandCenter.playCommand) { _ in
+ Task { @MainActor in
+ actions.play()
+ }
+ return .success
+ }
+ addTarget(to: commandCenter.pauseCommand) { _ in
+ Task { @MainActor in
+ actions.pause()
+ }
+ return .success
+ }
+ addTarget(to: commandCenter.togglePlayPauseCommand) { _ in
+ Task { @MainActor in
+ actions.togglePlayPause()
+ }
+ return .success
+ }
+ addTarget(to: commandCenter.stopCommand) { _ in
+ Task { @MainActor in
+ actions.stop()
+ }
+ return .success
+ }
+ addTarget(to: commandCenter.changePlaybackPositionCommand) { event in
+ guard let positionEvent = event as? MPChangePlaybackPositionCommandEvent else {
+ return .commandFailed
+ }
+ guard positionEvent.positionTime.isFinite else {
+ return .commandFailed
+ }
+
+ Task { @MainActor in
+ actions.seek(positionEvent.positionTime)
+ }
+ return .success
+ }
+ commandCenter.skipBackwardCommand.preferredIntervals = [
+ NSNumber(value: PlexPlaybackSeek.skipInterval)
+ ]
+ addTarget(to: commandCenter.skipBackwardCommand) { event in
+ guard let skipEvent = event as? MPSkipIntervalCommandEvent,
+ let offset = PlexPlaybackSkipDirection.backward.offset(
+ for: skipEvent.interval
+ ) else {
+ return .commandFailed
+ }
+
+ Task { @MainActor in
+ actions.skip(offset)
+ }
+ return .success
+ }
+ commandCenter.skipForwardCommand.preferredIntervals = [
+ NSNumber(value: PlexPlaybackSeek.skipInterval)
+ ]
+ addTarget(to: commandCenter.skipForwardCommand) { event in
+ guard let skipEvent = event as? MPSkipIntervalCommandEvent,
+ let offset = PlexPlaybackSkipDirection.forward.offset(
+ for: skipEvent.interval
+ ) else {
+ return .commandFailed
+ }
+
+ Task { @MainActor in
+ actions.skip(offset)
+ }
+ return .success
+ }
+ addTarget(to: commandCenter.enableLanguageOptionCommand) { event in
+ guard let languageEvent = event as? MPChangeLanguageOptionCommandEvent,
+ languageEvent.setting == .nowPlayingItemOnly,
+ let selection = PlexNowPlayingLanguageSelection(
+ languageOption: languageEvent.languageOption
+ ) else {
+ return .commandFailed
+ }
+
+ Task { @MainActor in
+ switch selection {
+ case .audio(let streamID):
+ actions.selectAudioStream(streamID)
+ case .subtitle(let streamID):
+ actions.selectSubtitleStream(streamID)
+ }
+ }
+ return .success
+ }
+ addTarget(to: commandCenter.disableLanguageOptionCommand) { event in
+ guard let languageEvent = event as? MPChangeLanguageOptionCommandEvent,
+ languageEvent.setting == .nowPlayingItemOnly,
+ case .subtitle = PlexNowPlayingLanguageSelection(
+ languageOption: languageEvent.languageOption
+ ) else {
+ return .commandFailed
+ }
+
+ Task { @MainActor in
+ actions.selectSubtitleStream(nil)
+ }
+ return .success
+ }
+ commandCenter.changePlaybackRateCommand.supportedPlaybackRates = PlexPlaybackRate.allCases.map {
+ NSNumber(value: $0.rawValue)
+ }
+ addTarget(to: commandCenter.changePlaybackRateCommand) { event in
+ guard let rateEvent = event as? MPChangePlaybackRateCommandEvent,
+ let playbackRate = PlexPlaybackRate(
+ remoteCommandValue: rateEvent.playbackRate
+ ) else {
+ return .commandFailed
+ }
+
+ Task { @MainActor in
+ actions.changePlaybackRate(playbackRate)
+ }
+ return .success
+ }
+ addTarget(to: commandCenter.changeShuffleModeCommand) { event in
+ guard let shuffleEvent = event as? MPChangeShuffleModeCommandEvent else {
+ return .commandFailed
+ }
+
+ let isShuffled: Bool
+ switch shuffleEvent.shuffleType {
+ case .off:
+ isShuffled = false
+ case .items:
+ isShuffled = true
+ case .collections:
+ return .commandFailed
+ @unknown default:
+ return .commandFailed
+ }
+
+ Task { @MainActor in
+ actions.changeShuffle(isShuffled)
+ }
+ return .success
+ }
+ addTarget(to: commandCenter.changeRepeatModeCommand) { event in
+ guard let repeatEvent = event as? MPChangeRepeatModeCommandEvent,
+ let repeatMode = PlexPlaybackRepeatMode(
+ remoteCommandValue: repeatEvent.repeatType
+ ), repeatMode != .all || canRepeatAll else {
+ return .commandFailed
+ }
+
+ Task { @MainActor in
+ actions.changeRepeatMode(repeatMode)
+ }
+ return .success
+ }
+ addTarget(to: commandCenter.previousTrackCommand) { _ in
+ Task { @MainActor in
+ actions.previous()
+ }
+ return .success
+ }
+ addTarget(to: commandCenter.nextTrackCommand) { _ in
+ Task { @MainActor in
+ actions.next()
+ }
+ return .success
+ }
+ }
+
+ private func addTarget(
+ to command: MPRemoteCommand,
+ handler: @escaping (MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus
+ ) {
+ let target = command.addTarget(handler: handler)
+ commandTargets.append((command, target))
+ }
+
+ private func setOwnedCommandsEnabled(_ isEnabled: Bool) {
+ commandCenter.playCommand.isEnabled = isEnabled
+ commandCenter.pauseCommand.isEnabled = isEnabled
+ commandCenter.togglePlayPauseCommand.isEnabled = isEnabled
+ commandCenter.stopCommand.isEnabled = isEnabled
+ commandCenter.changePlaybackPositionCommand.isEnabled = isEnabled
+ commandCenter.skipBackwardCommand.isEnabled = isEnabled
+ commandCenter.skipForwardCommand.isEnabled = isEnabled
+ if !isEnabled {
+ commandCenter.enableLanguageOptionCommand.isEnabled = false
+ commandCenter.disableLanguageOptionCommand.isEnabled = false
+ }
+ commandCenter.changePlaybackRateCommand.isEnabled = isEnabled
+ commandCenter.changeShuffleModeCommand.isEnabled = isEnabled
+ commandCenter.changeRepeatModeCommand.isEnabled = isEnabled
+ if !isEnabled {
+ commandCenter.changePlaybackRateCommand.supportedPlaybackRates = []
+ commandCenter.skipBackwardCommand.preferredIntervals = []
+ commandCenter.skipForwardCommand.preferredIntervals = []
+ commandCenter.changeShuffleModeCommand.currentShuffleType = .off
+ commandCenter.changeRepeatModeCommand.currentRepeatType = .off
+ }
+ commandCenter.previousTrackCommand.isEnabled = false
+ commandCenter.nextTrackCommand.isEnabled = false
+ }
+
+ private func publish(metadata: PlexNowPlayingMetadata, status: PlexPlaybackStatus) {
+ infoCenter.nowPlayingInfo = metadata.nowPlayingInfo(
+ artwork: artwork,
+ languageOptions: languageOptions
+ )
+ infoCenter.playbackState = status.nowPlayingPlaybackState
+ lastMetadata = metadata
+ lastStatus = status
+ }
+
+ private func updateLanguageCommandAvailability() {
+ applyLanguageCommandAvailability()
+ }
+
+ private func applyLanguageCommandAvailability() {
+ commandCenter.enableLanguageOptionCommand.isEnabled =
+ canChangeLanguageOptions && languageOptions.hasAvailableOptions
+ commandCenter.disableLanguageOptionCommand.isEnabled =
+ canChangeLanguageOptions && languageOptions.hasSelectedSubtitle
+ }
+}
+#endif
+
+private extension PlexMediaItem {
+ var nowPlayingCollectionRatingKey: String? {
+ switch type?.lowercased() {
+ case "episode":
+ grandparentRatingKey?.nilIfBlank ?? parentRatingKey?.nilIfBlank
+ case "track":
+ parentRatingKey?.nilIfBlank ?? grandparentRatingKey?.nilIfBlank
+ default:
+ nil
+ }
+ }
+}
+
+#if os(macOS)
+private extension PlexPlaybackRepeatMode {
+ init?(remoteCommandValue: MPRepeatType) {
+ switch remoteCommandValue {
+ case .off:
+ self = .off
+ case .one:
+ self = .one
+ case .all:
+ self = .all
+ @unknown default:
+ return nil
+ }
+ }
+
+ var remoteCommandValue: MPRepeatType {
+ switch self {
+ case .off: .off
+ case .one: .one
+ case .all: .all
+ }
+ }
+}
+
+private extension PlexPlaybackStatus {
+ var nowPlayingPlaybackState: MPNowPlayingPlaybackState {
+ switch self {
+ case .playing:
+ .playing
+ case .paused:
+ .paused
+ case .preparing, .buffering:
+ .interrupted
+ case .idle, .ended, .failed:
+ .stopped
+ }
+ }
+}
+#endif
diff --git a/PlexBar/Playback/PlexPlaybackBandwidthRegistry.swift b/PlexBar/Playback/PlexPlaybackBandwidthRegistry.swift
new file mode 100644
index 0000000..e6182fe
--- /dev/null
+++ b/PlexBar/Playback/PlexPlaybackBandwidthRegistry.swift
@@ -0,0 +1,196 @@
+import PlexModels
+import Foundation
+
+struct PlexPlaybackBandwidthRecord: Codable, Equatable, Identifiable, Sendable {
+ let serverIdentifier: String
+ let bitsPerSecond: Double
+ let measuredAt: Date
+ let byteCount: Int64
+ let transferDuration: TimeInterval
+
+ var id: String { serverIdentifier }
+
+ init(
+ serverIdentifier: String,
+ sample: PlexPlaybackBandwidthSample
+ ) {
+ self.serverIdentifier = serverIdentifier
+ bitsPerSecond = sample.bitsPerSecond
+ measuredAt = sample.measuredAt
+ byteCount = sample.byteCount
+ transferDuration = sample.transferDuration
+ }
+}
+
+actor PlexPlaybackBandwidthRegistry {
+ private struct Document: Codable {
+ static let currentSchemaVersion = 1
+
+ let schemaVersion: Int
+ let records: [PlexPlaybackBandwidthRecord]
+ }
+
+ static let retainedServerLimit = 32
+
+ private let rootURL: URL
+ private var cachedRecords: [String: PlexPlaybackBandwidthRecord]?
+
+ init(rootURL: URL = PlexPlaybackBandwidthRegistry.defaultRootURL()) {
+ self.rootURL = rootURL.standardizedFileURL.resolvingSymlinksInPath()
+ }
+
+ static func defaultRootURL() -> URL {
+ URL.applicationSupportDirectory
+ .appendingPathComponent(AppConstants.appName, isDirectory: true)
+ .appendingPathComponent("Playback", isDirectory: true)
+ }
+
+ func record(
+ _ sample: PlexPlaybackBandwidthSample,
+ for serverIdentifier: String
+ ) throws {
+ guard let serverIdentifier = serverIdentifier.nilIfBlank else {
+ throw PlexPlaybackBandwidthRegistryError.invalidServerIdentifier
+ }
+
+ let record = PlexPlaybackBandwidthRecord(
+ serverIdentifier: serverIdentifier,
+ sample: sample
+ )
+ var records = try loadIfNeeded()
+ if let existing = records[serverIdentifier] {
+ guard record.measuredAt >= existing.measuredAt,
+ record != existing else {
+ return
+ }
+ }
+
+ records[serverIdentifier] = record
+ if records.count > Self.retainedServerLimit {
+ let identifiersToRemove = records.values
+ .sorted {
+ if $0.measuredAt != $1.measuredAt {
+ return $0.measuredAt < $1.measuredAt
+ }
+ return $0.serverIdentifier < $1.serverIdentifier
+ }
+ .prefix(records.count - Self.retainedServerLimit)
+ .map(\.serverIdentifier)
+ for identifier in identifiersToRemove {
+ records[identifier] = nil
+ }
+ }
+
+ try persist(records)
+ cachedRecords = records
+ }
+
+ func record(for serverIdentifier: String) throws -> PlexPlaybackBandwidthRecord? {
+ guard let serverIdentifier = serverIdentifier.nilIfBlank else {
+ throw PlexPlaybackBandwidthRegistryError.invalidServerIdentifier
+ }
+ return try loadIfNeeded()[serverIdentifier]
+ }
+
+ func records() throws -> [PlexPlaybackBandwidthRecord] {
+ try loadIfNeeded().values.sorted {
+ if $0.measuredAt != $1.measuredAt {
+ return $0.measuredAt > $1.measuredAt
+ }
+ return $0.serverIdentifier < $1.serverIdentifier
+ }
+ }
+
+ private func loadIfNeeded() throws -> [String: PlexPlaybackBandwidthRecord] {
+ if let cachedRecords {
+ return cachedRecords
+ }
+ guard FileManager.default.fileExists(atPath: registryURL.path) else {
+ cachedRecords = [:]
+ return [:]
+ }
+
+ let document: Document
+ do {
+ document = try Self.decoder.decode(
+ Document.self,
+ from: Data(contentsOf: registryURL)
+ )
+ } catch {
+ throw PlexPlaybackBandwidthRegistryError.invalidRegistry
+ }
+ guard document.schemaVersion == Document.currentSchemaVersion,
+ document.records.count <= Self.retainedServerLimit,
+ Set(document.records.map(\.serverIdentifier)).count == document.records.count,
+ document.records.allSatisfy(Self.isValid) else {
+ throw PlexPlaybackBandwidthRegistryError.invalidRegistry
+ }
+
+ let records = Dictionary(
+ uniqueKeysWithValues: document.records.map { ($0.serverIdentifier, $0) }
+ )
+ cachedRecords = records
+ return records
+ }
+
+ private func persist(_ records: [String: PlexPlaybackBandwidthRecord]) throws {
+ do {
+ try FileManager.default.createDirectory(
+ at: rootURL,
+ withIntermediateDirectories: true
+ )
+ let document = Document(
+ schemaVersion: Document.currentSchemaVersion,
+ records: records.values.sorted {
+ $0.serverIdentifier < $1.serverIdentifier
+ }
+ )
+ try Self.encoder.encode(document).write(to: registryURL, options: .atomic)
+ } catch {
+ throw PlexPlaybackBandwidthRegistryError.registryUnavailable
+ }
+ }
+
+ private var registryURL: URL {
+ rootURL.appendingPathComponent("bandwidth-history.json")
+ }
+
+ private static func isValid(_ record: PlexPlaybackBandwidthRecord) -> Bool {
+ record.serverIdentifier.nilIfBlank != nil
+ && record.bitsPerSecond.isFinite
+ && record.bitsPerSecond > 0
+ && record.byteCount > 0
+ && record.transferDuration.isFinite
+ && record.transferDuration > 0
+ }
+
+ private static var encoder: JSONEncoder {
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ encoder.outputFormatting = [.sortedKeys]
+ return encoder
+ }
+
+ private static var decoder: JSONDecoder {
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return decoder
+ }
+}
+
+enum PlexPlaybackBandwidthRegistryError: LocalizedError, Equatable {
+ case invalidServerIdentifier
+ case invalidRegistry
+ case registryUnavailable
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidServerIdentifier:
+ "Plex did not provide a valid server identity for this bandwidth sample."
+ case .invalidRegistry:
+ "The saved playback bandwidth history is invalid."
+ case .registryUnavailable:
+ "PlexBar could not save playback bandwidth history."
+ }
+ }
+}
diff --git a/PlexBar/Playback/PlexPlaybackCommands.swift b/PlexBar/Playback/PlexPlaybackCommands.swift
new file mode 100644
index 0000000..4047e9e
--- /dev/null
+++ b/PlexBar/Playback/PlexPlaybackCommands.swift
@@ -0,0 +1,226 @@
+import SwiftUI
+
+extension FocusedValues {
+ @Entry var plexPlayerSurfaceIsFocused: Bool?
+}
+
+enum PlexPlaybackKeyboardShortcut {
+ static func playPause(isPlayerSurfaceFocused: Bool) -> KeyboardShortcut? {
+ guard isPlayerSurfaceFocused else {
+ return nil
+ }
+ return KeyboardShortcut(.space, modifiers: [])
+ }
+}
+
+struct PlexPlaybackCommands: Commands {
+ @Environment(\.openWindow) private var openWindow
+ @FocusedValue(\.plexPlayerSurfaceIsFocused) private var playerSurfaceIsFocused
+ @Bindable var coordinator: PlexPlayerCoordinator
+ @Bindable var settingsStore: PlexSettingsStore
+
+ var body: some Commands {
+ CommandMenu("Playback") {
+ Button("Show Player", systemImage: "play.rectangle") {
+ openWindow(id: PlexMainNavigationStore.windowID)
+ }
+ .disabled(coordinator.presentation == nil)
+
+ Divider()
+
+ Button(
+ coordinator.transportAction?.title ?? "Play",
+ systemImage: coordinator.transportAction?.systemImage ?? "play.fill",
+ action: coordinator.togglePlayback
+ )
+ .keyboardShortcut(PlexPlaybackKeyboardShortcut.playPause(
+ isPlayerSurfaceFocused: playerSurfaceIsFocused == true
+ ))
+ .disabled(coordinator.transportAction == nil)
+
+ Button("Stop", systemImage: "stop.fill", action: coordinator.stopPlayback)
+ .disabled(!coordinator.canStop)
+
+ Divider()
+
+ Menu("Playback Speed") {
+ ForEach(PlexPlaybackRate.allCases) { playbackRate in
+ Button {
+ coordinator.selectPlaybackRate(playbackRate)
+ } label: {
+ if coordinator.playbackRate == playbackRate {
+ Label(playbackRate.label, systemImage: "checkmark")
+ } else {
+ Text(playbackRate.label)
+ }
+ }
+ }
+ }
+ .disabled(!coordinator.canChangePlaybackRate)
+
+ Menu("Video Quality") {
+ ForEach(PlexVideoQuality.allCases) { videoQuality in
+ Button {
+ coordinator.selectVideoQuality(videoQuality)
+ } label: {
+ if coordinator.videoQualitySelection.selectedQuality == videoQuality {
+ Label(videoQuality.label, systemImage: "checkmark")
+ } else {
+ Text(videoQuality.label)
+ }
+ }
+ .disabled(!coordinator.videoQualitySelection.canSelect(videoQuality))
+ }
+ }
+ .disabled(!coordinator.videoQualitySelection.isVideo)
+
+ Menu("Video Dynamic Range") {
+ ForEach(PlexVideoDisplayDynamicRange.allCases) { dynamicRange in
+ Button {
+ settingsStore.videoDynamicRange = dynamicRange
+ } label: {
+ if settingsStore.videoDynamicRange == dynamicRange {
+ Label(dynamicRange.label, systemImage: "checkmark")
+ } else {
+ Text(dynamicRange.label)
+ }
+ }
+ }
+ }
+ .disabled(!coordinator.videoQualitySelection.isVideo)
+
+ Menu("Video Scaling") {
+ ForEach(PlexVideoScalingMode.allCases) { scalingMode in
+ Button {
+ settingsStore.videoScalingMode = scalingMode
+ } label: {
+ if settingsStore.videoScalingMode == scalingMode {
+ Label(scalingMode.label, systemImage: "checkmark")
+ } else {
+ Text(scalingMode.label)
+ }
+ }
+ }
+ }
+ .disabled(!coordinator.videoQualitySelection.isVideo)
+
+ if coordinator.serverManagedMediaSelection.hasChoices {
+ Divider()
+
+ if !coordinator.serverManagedMediaSelection.audioOptions.isEmpty {
+ Menu("Audio Track") {
+ ForEach(coordinator.serverManagedMediaSelection.audioOptions) { option in
+ Button {
+ coordinator.selectAudioStream(option.id)
+ } label: {
+ if option.isSelected {
+ Label(option.title, systemImage: "checkmark")
+ } else {
+ Text(option.title)
+ }
+ }
+ }
+ }
+ .disabled(!coordinator.canChangeServerManagedMediaSelection)
+ }
+
+ if !coordinator.serverManagedMediaSelection.subtitleOptions.isEmpty {
+ Menu("Subtitles") {
+ Button {
+ coordinator.selectSubtitleStream(nil)
+ } label: {
+ if coordinator.serverManagedMediaSelection.subtitleOptions
+ .contains(where: \.isSelected) {
+ Text("Off")
+ } else {
+ Label("Off", systemImage: "checkmark")
+ }
+ }
+
+ Divider()
+
+ ForEach(coordinator.serverManagedMediaSelection.subtitleOptions) { option in
+ Button {
+ coordinator.selectSubtitleStream(option.id)
+ } label: {
+ if option.isSelected {
+ Label(option.title, systemImage: "checkmark")
+ } else {
+ Text(option.title)
+ }
+ }
+ }
+ }
+ .disabled(!coordinator.canChangeServerManagedMediaSelection)
+ }
+ }
+
+ Toggle(
+ "Shuffle",
+ isOn: Binding(
+ get: { coordinator.isShuffled },
+ set: { isShuffled in
+ coordinator.setShuffled(isShuffled)
+ }
+ )
+ )
+ .disabled(!coordinator.canChangeShuffle)
+
+ Menu("Repeat") {
+ ForEach(PlexPlaybackRepeatMode.allCases) { repeatMode in
+ Button {
+ coordinator.selectRepeatMode(repeatMode)
+ } label: {
+ if coordinator.repeatMode == repeatMode {
+ Label(repeatMode.label, systemImage: "checkmark")
+ } else {
+ Text(repeatMode.label)
+ }
+ }
+ .disabled(repeatMode == .all && !coordinator.canRepeatAll)
+ }
+ }
+ .disabled(!coordinator.canChangeRepeatMode)
+
+ Divider()
+
+ Button(
+ "Skip Backward 10 Seconds",
+ systemImage: "gobackward.10",
+ action: coordinator.skipBackward
+ )
+ .disabled(!coordinator.canSeek)
+
+ Button(
+ "Skip Forward 10 Seconds",
+ systemImage: "goforward.10",
+ action: coordinator.skipForward
+ )
+ .disabled(!coordinator.canSeek)
+
+ Divider()
+
+ Button("Previous", action: coordinator.goPrevious)
+ .disabled(!coordinator.canGoPrevious)
+
+ Button("Next", action: coordinator.goNext)
+ .disabled(!coordinator.canGoNext)
+ }
+ }
+}
+
+private extension PlexPlaybackTransportAction {
+ var title: String {
+ switch self {
+ case .play: "Play"
+ case .pause: "Pause"
+ }
+ }
+
+ var systemImage: String {
+ switch self {
+ case .play: "play.fill"
+ case .pause: "pause.fill"
+ }
+ }
+}
diff --git a/PlexBar/Playback/PlexPlaybackDecisionResolver.swift b/PlexBar/Playback/PlexPlaybackDecisionResolver.swift
new file mode 100644
index 0000000..16cc6d3
--- /dev/null
+++ b/PlexBar/Playback/PlexPlaybackDecisionResolver.swift
@@ -0,0 +1,60 @@
+import PlexModels
+import Foundation
+
+struct PlexPlaybackSelection: Equatable, Sendable {
+ let method: PlexPlaybackPlan.Method
+ let path: String
+ let supportsAudioBoost: Bool
+
+ init(
+ method: PlexPlaybackPlan.Method,
+ path: String,
+ supportsAudioBoost: Bool = false
+ ) {
+ self.method = method
+ self.path = path
+ self.supportsAudioBoost = supportsAudioBoost
+ }
+}
+
+enum PlexPlaybackDecisionResolution: Equatable, Sendable {
+ case selected(PlexPlaybackSelection)
+ case rejected(String)
+ case noPlayableMedia
+}
+
+enum PlexPlaybackDecisionResolver {
+ static func resolve(
+ _ decision: PlexPlaybackDecisionContainer,
+ mediaKind: PlexPlaybackMediaKind
+ ) -> PlexPlaybackDecisionResolution {
+ if let code = decision.generalDecisionCode,
+ !(1_000..<2_000).contains(code) {
+ return .rejected(decision.generalDecisionText ?? "decision code \(code)")
+ }
+
+ guard let item = decision.metadata.first,
+ let media = item.media.first(where: { $0.selected == true }) ?? item.media.first,
+ let part = media.parts.first(where: { $0.selected == true }) ?? media.parts.first else {
+ return .noPlayableMedia
+ }
+
+ if part.decision?.lowercased() == "directplay", let key = part.key?.nilIfBlank {
+ return .selected(PlexPlaybackSelection(method: .directPlay, path: key))
+ }
+
+ let transcodes = part.streams.contains { stream in
+ ["transcode", "burn"].contains(stream.decision?.lowercased())
+ }
+ let supportsAudioBoost = part.streams.contains { stream in
+ stream.streamType == 2
+ && ["transcode", "burn"].contains(stream.decision?.lowercased())
+ && stream.channels == 2
+ }
+ return .selected(PlexPlaybackSelection(
+ method: transcodes ? .transcode : .directStream,
+ path: mediaKind.startPath,
+ supportsAudioBoost: supportsAudioBoost
+ ))
+ }
+}
diff --git a/PlexBar/Playback/PlexPlaybackEngine.swift b/PlexBar/Playback/PlexPlaybackEngine.swift
new file mode 100644
index 0000000..92a41a7
--- /dev/null
+++ b/PlexBar/Playback/PlexPlaybackEngine.swift
@@ -0,0 +1,438 @@
+import AVFoundation
+import Observation
+
+enum PlexNativePlayerRecoveryPolicy {
+ static func requiresReplacement(status: AVPlayer.Status) -> Bool {
+ status == .failed
+ }
+}
+
+@MainActor
+@Observable
+final class PlexPlaybackEngine {
+ private(set) var status: PlexPlaybackStatus = .idle
+ private(set) var waitingReason: PlexPlaybackWaitingReason?
+ private(set) var position: TimeInterval = 0
+ private(set) var duration: TimeInterval?
+ private(set) var mediaFacts: PlexNativeMediaFacts?
+ private(set) var metricFacts: PlexPlaybackMetricFacts?
+ private(set) var playbackRate: PlexPlaybackRate = .normal
+ private(set) var unexpectedTimeJumpRevision: UInt = 0
+
+ private(set) var player = AVPlayer()
+ @ObservationIgnored private var monitorTask: Task?
+ @ObservationIgnored private var endObservationTask: Task?
+ @ObservationIgnored private var timeJumpObservationTask: Task?
+ @ObservationIgnored private var mediaInspectionTask: Task?
+ @ObservationIgnored private var metricsTask: Task?
+ @ObservationIgnored private var inspectedItemIdentifier: ObjectIdentifier?
+ @ObservationIgnored private var pendingStartTime: TimeInterval?
+ @ObservationIgnored private var autoplayAfterPendingSeek = true
+ @ObservationIgnored private var seekSequence = PlexPlaybackSeekSequence()
+ @ObservationIgnored private var expectedTimeJumps = PlexPlaybackTimeJumpExpectations()
+
+ func load(plan: PlexPlaybackPlan, autoplay: Bool = true) async throws {
+ let playerRequiresReplacement = PlexNativePlayerRecoveryPolicy.requiresReplacement(
+ status: player.status
+ )
+ stop()
+ if playerRequiresReplacement {
+ player = AVPlayer()
+ }
+ updateWaitingReason(nil)
+ status = .preparing
+
+ let asset = AVURLAsset(url: plan.url)
+ let item = AVPlayerItem(asset: asset)
+ player.preventsDisplaySleepDuringVideoPlayback = plan.mediaKind == .video
+ player.defaultRate = playbackRate.rawValue
+ player.replaceCurrentItem(with: item)
+ duration = plan.duration
+ pendingStartTime = plan.startTime > 0 ? plan.startTime : nil
+ autoplayAfterPendingSeek = autoplay
+ observeEnd(of: item)
+ observeTimeJumps(of: item)
+ observeMetrics(
+ of: item,
+ playbackMethod: plan.method,
+ mediaKind: plan.mediaKind
+ )
+ startMonitoring()
+ if autoplay {
+ player.play()
+ } else {
+ player.pause()
+ status = .paused
+ }
+ }
+
+ func play() {
+ autoplayAfterPendingSeek = true
+ player.defaultRate = playbackRate.rawValue
+ player.play()
+ updateWaitingReason(nil)
+ status = .playing
+ }
+
+ func pause() {
+ autoplayAfterPendingSeek = false
+ player.pause()
+ updateWaitingReason(nil)
+ status = .paused
+ }
+
+ func setPlaybackRate(_ playbackRate: PlexPlaybackRate) {
+ guard self.playbackRate != playbackRate else {
+ return
+ }
+
+ self.playbackRate = playbackRate
+ player.defaultRate = playbackRate.rawValue
+ switch status {
+ case .preparing, .playing, .buffering:
+ player.play()
+ case .idle, .paused, .ended, .failed:
+ break
+ }
+ }
+
+ func reserveSeek(to position: TimeInterval) -> PlexPlaybackSeekSequence.Request {
+ seekSequence.reserve(absoluteTarget: position, duration: duration)
+ }
+
+ func reserveSkip(
+ by offset: TimeInterval,
+ duration: TimeInterval?
+ ) -> PlexPlaybackSeekSequence.Request {
+ seekSequence.reserve(
+ relativeOffset: offset,
+ currentPosition: position,
+ duration: duration ?? self.duration
+ )
+ }
+
+ func performSeek(_ request: PlexPlaybackSeekSequence.Request) async -> Bool {
+ guard seekSequence.isCurrent(request) else {
+ return false
+ }
+ let item = player.currentItem
+ let timeJumpToken = expectedTimeJumps.expect(target: request.target)
+ let completed = await player.seek(
+ to: CMTime(seconds: request.target, preferredTimescale: 600),
+ toleranceBefore: .zero,
+ toleranceAfter: .zero
+ )
+ guard completed, player.currentItem === item else {
+ expectedTimeJumps.cancel(timeJumpToken)
+ return false
+ }
+ guard seekSequence.finish(request) else {
+ return false
+ }
+ position = request.target
+ return true
+ }
+
+ @discardableResult
+ func seek(to position: TimeInterval) async -> Bool {
+ let request = reserveSeek(to: position)
+ return await performSeek(request)
+ }
+
+ func cancelPendingSeek() {
+ player.currentItem?.cancelPendingSeeks()
+ seekSequence.invalidate()
+ }
+
+ func stop() {
+ monitorTask?.cancel()
+ monitorTask = nil
+ endObservationTask?.cancel()
+ endObservationTask = nil
+ timeJumpObservationTask?.cancel()
+ timeJumpObservationTask = nil
+ mediaInspectionTask?.cancel()
+ mediaInspectionTask = nil
+ metricsTask?.cancel()
+ metricsTask = nil
+ inspectedItemIdentifier = nil
+ seekSequence.invalidate()
+ expectedTimeJumps.invalidate()
+ player.pause()
+ player.replaceCurrentItem(with: nil)
+ player.preventsDisplaySleepDuringVideoPlayback = false
+ position = 0
+ duration = nil
+ mediaFacts = nil
+ updateMetricFacts(nil)
+ pendingStartTime = nil
+ autoplayAfterPendingSeek = true
+ updateWaitingReason(nil)
+ status = .idle
+ }
+
+ private func startMonitoring() {
+ monitorTask?.cancel()
+ monitorTask = Task { [weak self] in
+ while !Task.isCancelled {
+ self?.refreshState()
+ try? await Task.sleep(for: .milliseconds(250))
+ }
+ }
+ }
+
+ private func observeEnd(of item: AVPlayerItem) {
+ endObservationTask?.cancel()
+ endObservationTask = Task { [weak self, weak item] in
+ guard let item else {
+ return
+ }
+ for await _ in NotificationCenter.default.notifications(
+ named: AVPlayerItem.didPlayToEndTimeNotification,
+ object: item
+ ) {
+ guard !Task.isCancelled else {
+ return
+ }
+ if let duration = self?.duration {
+ self?.position = duration
+ }
+ self?.updateWaitingReason(nil)
+ self?.status = .ended
+ }
+ }
+ }
+
+ private func observeTimeJumps(of item: AVPlayerItem) {
+ timeJumpObservationTask?.cancel()
+ timeJumpObservationTask = Task { [weak self, weak item] in
+ guard let item else {
+ return
+ }
+
+ for await _ in NotificationCenter.default.notifications(
+ named: AVPlayerItem.timeJumpedNotification,
+ object: item
+ ) {
+ guard !Task.isCancelled,
+ let self,
+ player.currentItem === item else {
+ return
+ }
+
+ let currentSeconds = player.currentTime().seconds
+ guard currentSeconds.isFinite else {
+ continue
+ }
+ let currentPosition = max(currentSeconds, 0)
+ position = currentPosition
+ guard !expectedTimeJumps.consume(position: currentPosition) else {
+ continue
+ }
+ unexpectedTimeJumpRevision &+= 1
+ }
+ }
+ }
+
+ private func inspectMediaIfNeeded(of item: AVPlayerItem) {
+ let itemIdentifier = ObjectIdentifier(item)
+ guard inspectedItemIdentifier != itemIdentifier else {
+ return
+ }
+ inspectedItemIdentifier = itemIdentifier
+ mediaInspectionTask?.cancel()
+ mediaInspectionTask = Task { [weak self, weak item] in
+ guard let self, let item else {
+ return
+ }
+
+ let facts = await PlexNativeMediaInspector.inspect(item: item)
+ guard !Task.isCancelled, player.currentItem === item else {
+ return
+ }
+ mediaFacts = facts
+ }
+ }
+
+ private func observeMetrics(
+ of item: AVPlayerItem,
+ playbackMethod: PlexPlaybackPlan.Method,
+ mediaKind: PlexPlaybackMediaKind
+ ) {
+ metricsTask?.cancel()
+ updateMetricFacts(nil)
+ metricsTask = Task { [weak self, weak item] in
+ guard let item else {
+ return
+ }
+
+ let metrics = item.metrics(forType: AVMetricPlayerItemStallEvent.self)
+ .chronologicalMerge(
+ with: item.metrics(
+ forType: AVMetricPlayerItemInitialLikelyToKeepUpEvent.self
+ ),
+ item.metrics(forType: AVMetricPlayerItemVariantSwitchEvent.self)
+ , item.metrics(forType: AVMetricHLSMediaSegmentRequestEvent.self)
+ , item.metrics(forType: AVMetricMediaResourceRequestEvent.self)
+ )
+ var facts = PlexPlaybackMetricFacts()
+
+ do {
+ for try await (event, publisher) in metrics {
+ guard !Task.isCancelled,
+ let self,
+ let publishedItem = publisher as? AVPlayerItem,
+ publishedItem === item,
+ player.currentItem === item else {
+ return
+ }
+
+ switch event {
+ case is AVMetricPlayerItemStallEvent:
+ facts.recordStall()
+ case let event as AVMetricPlayerItemInitialLikelyToKeepUpEvent:
+ facts.recordInitialLikelyToKeepUp(
+ timeTaken: event.timeTaken,
+ variant: PlexPlaybackVariantFacts(variant: event.variant)
+ )
+ case let event as AVMetricPlayerItemVariantSwitchEvent:
+ facts.recordVariantSwitch(
+ succeeded: event.didSucceed,
+ to: PlexPlaybackVariantFacts(variant: event.toVariant)
+ )
+ case let event as AVMetricHLSMediaSegmentRequestEvent:
+ guard playbackMethod != .directPlay, mediaKind == .video else {
+ continue
+ }
+ let sample = PlexPlaybackBandwidthSample(segment: event)
+ facts.recordBandwidthSample(sample)
+ case let event as AVMetricMediaResourceRequestEvent:
+ guard playbackMethod == .directPlay, mediaKind == .video else {
+ continue
+ }
+ let sample = PlexPlaybackBandwidthSample(resourceRequest: event)
+ facts.recordBandwidthSample(sample)
+ default:
+ continue
+ }
+ updateMetricFacts(facts)
+ }
+ } catch is CancellationError {
+ return
+ } catch {
+ return
+ }
+ }
+ }
+
+ private func updateMetricFacts(_ metricFacts: PlexPlaybackMetricFacts?) {
+ guard self.metricFacts != metricFacts else {
+ return
+ }
+ self.metricFacts = metricFacts
+ }
+
+ private func refreshState() {
+ if player.status == .failed {
+ updateWaitingReason(nil)
+ status = .failed(
+ player.error?.localizedDescription
+ ?? "macOS could no longer play this stream."
+ )
+ return
+ }
+
+ let currentSeconds = player.currentTime().seconds
+ if currentSeconds.isFinite {
+ position = max(currentSeconds, 0)
+ }
+
+ guard let item = player.currentItem else {
+ updateWaitingReason(nil)
+ status = .idle
+ return
+ }
+
+ guard refreshItemState(item) else {
+ return
+ }
+
+ if let startTime = pendingStartTime {
+ pendingStartTime = nil
+ Task { [weak self, weak item] in
+ guard let self, let item else {
+ return
+ }
+ let completed = await seek(to: startTime)
+ guard completed, player.currentItem === item else {
+ return
+ }
+ if autoplayAfterPendingSeek {
+ play()
+ } else {
+ pause()
+ }
+ }
+ }
+
+ refreshTimeControlState()
+ }
+
+ private func refreshItemState(_ item: AVPlayerItem) -> Bool {
+ if let error = item.error {
+ updateWaitingReason(nil)
+ status = .failed(error.localizedDescription)
+ return false
+ }
+
+ let itemDuration = item.duration.seconds
+ if itemDuration.isFinite, itemDuration > 0 {
+ duration = itemDuration
+ }
+
+ switch item.status {
+ case .unknown:
+ updateWaitingReason(nil)
+ if status != .paused {
+ status = .preparing
+ }
+ return false
+ case .failed:
+ updateWaitingReason(nil)
+ status = .failed(item.error?.localizedDescription ?? "macOS could not open the Plex stream.")
+ return false
+ case .readyToPlay:
+ inspectMediaIfNeeded(of: item)
+ return true
+ @unknown default:
+ return true
+ }
+ }
+
+ private func refreshTimeControlState() {
+ updateWaitingReason(PlexPlaybackWaitingReason(
+ timeControlStatus: player.timeControlStatus,
+ nativeReason: player.reasonForWaitingToPlay
+ ))
+
+ switch player.timeControlStatus {
+ case .paused:
+ if status != .preparing, status != .ended {
+ status = .paused
+ }
+ case .waitingToPlayAtSpecifiedRate:
+ status = .buffering
+ case .playing:
+ status = .playing
+ @unknown default:
+ break
+ }
+ }
+
+ private func updateWaitingReason(_ waitingReason: PlexPlaybackWaitingReason?) {
+ guard self.waitingReason != waitingReason else {
+ return
+ }
+ self.waitingReason = waitingReason
+ }
+}
diff --git a/PlexBar/Playback/PlexPlaybackInfoPresentation.swift b/PlexBar/Playback/PlexPlaybackInfoPresentation.swift
new file mode 100644
index 0000000..7535c5f
--- /dev/null
+++ b/PlexBar/Playback/PlexPlaybackInfoPresentation.swift
@@ -0,0 +1,99 @@
+import PlexModels
+import Foundation
+
+struct PlexPlaybackInfoPresentation: Equatable, Sendable {
+ struct Row: Equatable, Identifiable, Sendable {
+ let id: String
+ let label: String
+ let value: String
+
+ init(id: String, label: String, value: String) {
+ self.id = id
+ self.label = label
+ self.value = value
+ }
+
+ init(_ fact: PlexNativeMediaDiagnosticFact) {
+ id = fact.kind.rawValue
+ label = fact.label
+ value = fact.value
+ }
+ }
+
+ let item: PlexPlayerPlaybackInfoPresentation
+ let playbackRows: [Row]
+ let videoRows: [Row]
+ let audioRows: [Row]
+ let performanceRows: [Row]
+
+ init(
+ item: PlexMediaItem,
+ deliveryLabel: String,
+ connectionLabel: String? = nil,
+ videoQualityLabel: String?,
+ playbackVersionLabel: String? = nil,
+ queuePositionLabel: String? = nil,
+ waitingReasonLabel: String? = nil,
+ audioOutputLabel: String? = nil,
+ deliveredMediaFacts: PlexNativeMediaFacts?,
+ playbackMetricFacts: [PlexPlaybackMetricDiagnosticFact] = []
+ ) {
+ self.item = PlexPlayerPlaybackInfoPresentation(item: item)
+ var playbackRows = [
+ Row(id: "delivery", label: "Delivery", value: deliveryLabel),
+ ]
+ if let connectionLabel {
+ playbackRows.append(Row(
+ id: "connection",
+ label: "Connection",
+ value: connectionLabel
+ ))
+ }
+ if let videoQualityLabel {
+ playbackRows.append(Row(
+ id: "quality",
+ label: "Quality",
+ value: videoQualityLabel
+ ))
+ }
+ if let playbackVersionLabel {
+ playbackRows.append(Row(
+ id: "version",
+ label: "Version",
+ value: playbackVersionLabel
+ ))
+ }
+ if let queuePositionLabel {
+ playbackRows.append(Row(
+ id: "queue",
+ label: "Queue",
+ value: queuePositionLabel
+ ))
+ }
+ if let waitingReasonLabel {
+ playbackRows.append(Row(
+ id: "waiting",
+ label: "Waiting",
+ value: waitingReasonLabel
+ ))
+ }
+ self.playbackRows = playbackRows
+ videoRows = deliveredMediaFacts?.videoDiagnosticFacts.map(Row.init) ?? []
+ var audioRows = deliveredMediaFacts?.audioDiagnosticFacts.map(Row.init) ?? []
+ if let audioOutputLabel {
+ audioRows.append(Row(
+ id: "output",
+ label: "Output",
+ value: audioOutputLabel
+ ))
+ }
+ self.audioRows = audioRows
+ performanceRows = playbackMetricFacts.map { fact in
+ Row(
+ id: "metric.\(fact.kind.rawValue)",
+ label: fact.label,
+ value: fact.value
+ )
+ }
+ }
+}
diff --git a/PlexBar/Playback/PlexPlaybackMetrics.swift b/PlexBar/Playback/PlexPlaybackMetrics.swift
new file mode 100644
index 0000000..838a073
--- /dev/null
+++ b/PlexBar/Playback/PlexPlaybackMetrics.swift
@@ -0,0 +1,316 @@
+import AVFoundation
+import CoreGraphics
+import Foundation
+
+struct PlexPlaybackMetricDiagnosticFact: Equatable, Identifiable, Sendable {
+ enum Kind: String, Sendable {
+ case initialStartup
+ case stalls
+ case variantSwitches
+ case failedVariantSwitches
+ case currentVariant
+ case measuredBandwidth
+ }
+
+ let kind: Kind
+ let label: String
+ let value: String
+
+ var id: Kind { kind }
+}
+
+struct PlexPlaybackBandwidthSample: Equatable, Sendable {
+ let bitsPerSecond: Double
+ let measuredAt: Date
+ let byteCount: Int64
+ let transferDuration: TimeInterval
+
+ init?(
+ byteCount: Int64,
+ responseStartTime: Date,
+ responseEndTime: Date,
+ wasReadFromCache: Bool,
+ hadError: Bool
+ ) {
+ let transferDuration = responseEndTime.timeIntervalSince(responseStartTime)
+ guard !wasReadFromCache,
+ !hadError,
+ byteCount > 0,
+ transferDuration.isFinite,
+ transferDuration > 0 else {
+ return nil
+ }
+
+ let bitsPerSecond = (Double(byteCount) * 8) / transferDuration
+ guard bitsPerSecond.isFinite, bitsPerSecond > 0 else {
+ return nil
+ }
+
+ self.bitsPerSecond = bitsPerSecond
+ measuredAt = responseEndTime
+ self.byteCount = byteCount
+ self.transferDuration = transferDuration
+ }
+
+ init?(
+ resourceRequest event: AVMetricMediaResourceRequestEvent,
+ fallbackByteCount: Int64? = nil
+ ) {
+ let transaction = event.networkTransactionMetrics?.transactionMetrics
+ .reversed()
+ .first { transaction in
+ transaction.countOfResponseBodyBytesReceived > 0
+ && transaction.responseStartDate != nil
+ && transaction.responseEndDate != nil
+ }
+ if let transaction,
+ let responseStartTime = transaction.responseStartDate,
+ let responseEndTime = transaction.responseEndDate {
+ self.init(
+ byteCount: transaction.countOfResponseBodyBytesReceived,
+ responseStartTime: responseStartTime,
+ responseEndTime: responseEndTime,
+ wasReadFromCache: event.wasReadFromCache,
+ hadError: event.errorEvent != nil
+ )
+ return
+ }
+
+ let resourceByteCount = Int64(event.byteRange.length)
+ let byteCount = (resourceByteCount > 0 ? resourceByteCount : nil)
+ ?? fallbackByteCount
+ ?? 0
+ self.init(
+ byteCount: byteCount,
+ responseStartTime: event.responseStartTime,
+ responseEndTime: event.responseEndTime,
+ wasReadFromCache: event.wasReadFromCache,
+ hadError: event.errorEvent != nil
+ )
+ }
+
+ init?(segment event: AVMetricHLSMediaSegmentRequestEvent) {
+ guard event.mediaType == .video,
+ !event.isMapSegment,
+ let resourceRequest = event.mediaResourceRequestEvent else {
+ return nil
+ }
+ self.init(
+ resourceRequest: resourceRequest,
+ fallbackByteCount: Int64(event.byteRange.length)
+ )
+ }
+}
+
+struct PlexPlaybackVariantFacts: Equatable, Sendable {
+ let videoWidth: Int?
+ let videoHeight: Int?
+ let averageBitRate: Double?
+ let peakBitRate: Double?
+
+ init?(variant: AVAssetVariant?) {
+ guard let variant else {
+ return nil
+ }
+ self.init(
+ presentationSize: variant.videoAttributes?.presentationSize,
+ averageBitRate: variant.averageBitRate,
+ peakBitRate: variant.peakBitRate
+ )
+ }
+
+ init?(
+ presentationSize: CGSize?,
+ averageBitRate: Double?,
+ peakBitRate: Double?
+ ) {
+ let dimensions = Self.integralDimensions(presentationSize)
+ let averageBitRate = Self.positiveFinite(averageBitRate)
+ let peakBitRate = Self.positiveFinite(peakBitRate)
+ guard dimensions != nil || averageBitRate != nil || peakBitRate != nil else {
+ return nil
+ }
+
+ videoWidth = dimensions?.width
+ videoHeight = dimensions?.height
+ self.averageBitRate = averageBitRate
+ self.peakBitRate = peakBitRate
+ }
+
+ var label: String {
+ var components: [String] = []
+ if let videoWidth, let videoHeight {
+ components.append("\(videoWidth) × \(videoHeight)")
+ }
+ if let averageBitRate,
+ let label = PlexPlaybackMetricFormatter.bitRate(averageBitRate) {
+ components.append("\(label) average")
+ }
+ if let peakBitRate,
+ let label = PlexPlaybackMetricFormatter.bitRate(peakBitRate) {
+ components.append("\(label) peak")
+ }
+ return components.joined(separator: " · ")
+ }
+
+ private static func integralDimensions(_ size: CGSize?) -> (width: Int, height: Int)? {
+ guard let size,
+ size.width.isFinite,
+ size.height.isFinite,
+ size.width > 0,
+ size.height > 0,
+ let width = Int(exactly: Double(size.width)),
+ let height = Int(exactly: Double(size.height)) else {
+ return nil
+ }
+ return (width, height)
+ }
+
+ private static func positiveFinite(_ value: Double?) -> Double? {
+ guard let value, value.isFinite, value > 0 else {
+ return nil
+ }
+ return value
+ }
+}
+
+struct PlexPlaybackMetricFacts: Equatable, Sendable {
+ private(set) var receivedEvent = false
+ private(set) var initialStartupTime: TimeInterval?
+ private(set) var stallCount = 0
+ private(set) var successfulVariantSwitchCount = 0
+ private(set) var failedVariantSwitchCount = 0
+ private(set) var currentVariant: PlexPlaybackVariantFacts?
+ private(set) var previousMeasuredBandwidth: PlexPlaybackBandwidthSample?
+ private(set) var lastMeasuredBandwidth: PlexPlaybackBandwidthSample?
+
+ mutating func recordInitialLikelyToKeepUp(
+ timeTaken: TimeInterval,
+ variant: PlexPlaybackVariantFacts?
+ ) {
+ receivedEvent = true
+ initialStartupTime = PlexPlaybackMetricFormatter.nonnegativeFinite(timeTaken)
+ currentVariant = variant
+ }
+
+ mutating func recordStall() {
+ receivedEvent = true
+ stallCount += 1
+ }
+
+ mutating func recordVariantSwitch(
+ succeeded: Bool,
+ to variant: PlexPlaybackVariantFacts?
+ ) {
+ receivedEvent = true
+ if succeeded {
+ successfulVariantSwitchCount += 1
+ currentVariant = variant
+ } else {
+ failedVariantSwitchCount += 1
+ }
+ }
+
+ mutating func recordBandwidthSample(_ sample: PlexPlaybackBandwidthSample?) {
+ guard let sample else {
+ return
+ }
+ receivedEvent = true
+ previousMeasuredBandwidth = lastMeasuredBandwidth
+ lastMeasuredBandwidth = sample
+ }
+
+ var diagnosticFacts: [PlexPlaybackMetricDiagnosticFact] {
+ guard receivedEvent else {
+ return []
+ }
+
+ var facts: [PlexPlaybackMetricDiagnosticFact] = []
+ if let initialStartupTime,
+ let label = PlexPlaybackMetricFormatter.duration(initialStartupTime) {
+ facts.append(.init(
+ kind: .initialStartup,
+ label: "Initial Startup",
+ value: label
+ ))
+ }
+ facts.append(.init(
+ kind: .stalls,
+ label: "Playback Stalls",
+ value: stallCount.formatted(.number.locale(Locale(identifier: "en_US_POSIX")))
+ ))
+ if successfulVariantSwitchCount > 0 {
+ facts.append(.init(
+ kind: .variantSwitches,
+ label: "Successful Variant Switches",
+ value: successfulVariantSwitchCount.formatted(
+ .number.locale(Locale(identifier: "en_US_POSIX"))
+ )
+ ))
+ }
+ if failedVariantSwitchCount > 0 {
+ facts.append(.init(
+ kind: .failedVariantSwitches,
+ label: "Failed Variant Switches",
+ value: failedVariantSwitchCount.formatted(
+ .number.locale(Locale(identifier: "en_US_POSIX"))
+ )
+ ))
+ }
+ if let currentVariant {
+ facts.append(.init(
+ kind: .currentVariant,
+ label: "Current Variant",
+ value: currentVariant.label
+ ))
+ }
+ if let lastMeasuredBandwidth,
+ let label = PlexPlaybackMetricFormatter.bitRate(
+ lastMeasuredBandwidth.bitsPerSecond
+ ) {
+ facts.append(.init(
+ kind: .measuredBandwidth,
+ label: "Last Measured Bandwidth",
+ value: label
+ ))
+ }
+ return facts
+ }
+}
+
+private enum PlexPlaybackMetricFormatter {
+ static func nonnegativeFinite(_ value: Double?) -> Double? {
+ guard let value, value.isFinite, value >= 0 else {
+ return nil
+ }
+ return value
+ }
+
+ static func duration(_ seconds: Double?) -> String? {
+ guard let seconds = nonnegativeFinite(seconds) else {
+ return nil
+ }
+ return "\(number(seconds, maximumFractionDigits: 3)) sec"
+ }
+
+ static func bitRate(_ bitsPerSecond: Double?) -> String? {
+ guard let bitsPerSecond, bitsPerSecond.isFinite, bitsPerSecond > 0 else {
+ return nil
+ }
+ if bitsPerSecond >= 1_000_000 {
+ return "\(number(bitsPerSecond / 1_000_000, maximumFractionDigits: 3)) Mbps"
+ }
+ if bitsPerSecond >= 1_000 {
+ return "\(number(bitsPerSecond / 1_000, maximumFractionDigits: 3)) kbps"
+ }
+ return "\(number(bitsPerSecond, maximumFractionDigits: 0)) bps"
+ }
+
+ private static func number(_ value: Double, maximumFractionDigits: Int) -> String {
+ value.formatted(
+ .number
+ .locale(Locale(identifier: "en_US_POSIX"))
+ .precision(.fractionLength(0...maximumFractionDigits))
+ )
+ }
+}
diff --git a/PlexBar/Playback/PlexPlaybackQualitySuggestions.swift b/PlexBar/Playback/PlexPlaybackQualitySuggestions.swift
new file mode 100644
index 0000000..9b39758
--- /dev/null
+++ b/PlexBar/Playback/PlexPlaybackQualitySuggestions.swift
@@ -0,0 +1,179 @@
+import Foundation
+
+struct PlexPlaybackQualitySuggestion: Equatable, Identifiable, Sendable {
+ enum Reason: Equatable, Sendable {
+ case repeatedStalls(count: Int)
+ case improvedBandwidth
+ }
+
+ let targetQuality: PlexVideoQuality
+ let reason: Reason
+
+ var id: String {
+ switch reason {
+ case .repeatedStalls:
+ "lower-\(targetQuality.rawValue)"
+ case .improvedBandwidth:
+ "higher-\(targetQuality.rawValue)"
+ }
+ }
+
+ var message: String {
+ switch reason {
+ case .repeatedStalls(let count):
+ "Playback stalled \(count) times. Change to \(targetQuality.label) for this item?"
+ case .improvedBandwidth:
+ "Playback bandwidth increased. Change to \(targetQuality.label) for this item?"
+ }
+ }
+}
+
+enum PlexPlaybackQualitySuggestionPolicy {
+ static let requiredStallCount = 3
+
+ static func suggestion(
+ isEnabled: Bool,
+ selection: PlexVideoQualitySelection,
+ sourceBitrate: Int?,
+ maximumQuality: PlexVideoQuality,
+ isTranscoding: Bool,
+ metrics: PlexPlaybackMetricFacts?,
+ excludedQualities: Set
+ ) -> PlexPlaybackQualitySuggestion? {
+ guard isEnabled,
+ selection.isVideo,
+ selection.canChange,
+ let metrics else {
+ return nil
+ }
+
+ if metrics.stallCount >= requiredStallCount,
+ let targetQuality = lowerQuality(
+ than: selection.selectedQuality,
+ sourceBitrate: sourceBitrate,
+ maximumQuality: maximumQuality
+ ),
+ !excludedQualities.contains(targetQuality) {
+ return PlexPlaybackQualitySuggestion(
+ targetQuality: targetQuality,
+ reason: .repeatedStalls(count: metrics.stallCount)
+ )
+ }
+
+ guard isTranscoding,
+ let targetQuality = higherQuality(
+ than: selection.selectedQuality,
+ sourceBitrate: sourceBitrate,
+ maximumQuality: maximumQuality,
+ previousBandwidth: metrics.previousMeasuredBandwidth,
+ measuredBandwidth: metrics.lastMeasuredBandwidth
+ ),
+ !excludedQualities.contains(targetQuality) else {
+ return nil
+ }
+ return PlexPlaybackQualitySuggestion(
+ targetQuality: targetQuality,
+ reason: .improvedBandwidth
+ )
+ }
+
+ private static func lowerQuality(
+ than selectedQuality: PlexVideoQuality,
+ sourceBitrate: Int?,
+ maximumQuality: PlexVideoQuality
+ ) -> PlexVideoQuality? {
+ let currentBitrate: Int
+ if let selectedBitrate = selectedQuality.constraints?.bitrate {
+ currentBitrate = selectedBitrate
+ } else if let sourceBitrate, sourceBitrate > 0 {
+ currentBitrate = sourceBitrate
+ } else {
+ return nil
+ }
+
+ let maximumBitrate = maximumQuality.constraints?.bitrate
+ return PlexVideoQuality.allCases
+ .compactMap { quality -> (quality: PlexVideoQuality, bitrate: Int)? in
+ guard let bitrate = quality.constraints?.bitrate,
+ bitrate < currentBitrate,
+ maximumBitrate.map({ bitrate <= $0 }) ?? true else {
+ return nil
+ }
+ return (quality, bitrate)
+ }
+ .max(by: { $0.bitrate < $1.bitrate })?
+ .quality
+ }
+
+ private static func higherQuality(
+ than selectedQuality: PlexVideoQuality,
+ sourceBitrate: Int?,
+ maximumQuality: PlexVideoQuality,
+ previousBandwidth: PlexPlaybackBandwidthSample?,
+ measuredBandwidth: PlexPlaybackBandwidthSample?
+ ) -> PlexVideoQuality? {
+ guard selectedQuality != .original,
+ let currentBitrate = selectedQuality.constraints?.bitrate,
+ let sourceBitrate,
+ sourceBitrate > currentBitrate,
+ let previousBandwidth,
+ let measuredBandwidth,
+ measuredBandwidth.bitsPerSecond > previousBandwidth.bitsPerSecond else {
+ return nil
+ }
+
+ let availableBitrate = measuredBandwidth.bitsPerSecond / 1_000
+ let maximumBitrate = maximumQuality.constraints?.bitrate
+ if maximumQuality == .original,
+ Double(sourceBitrate) <= availableBitrate {
+ return .original
+ }
+
+ return PlexVideoQuality.allCases.compactMap {
+ quality -> (quality: PlexVideoQuality, bitrate: Int)? in
+ guard let bitrate = quality.constraints?.bitrate,
+ bitrate > currentBitrate,
+ bitrate <= sourceBitrate,
+ Double(bitrate) <= availableBitrate,
+ maximumBitrate.map({ bitrate <= $0 }) ?? true else {
+ return nil
+ }
+ return (quality, bitrate)
+ }
+ .max(by: { $0.bitrate < $1.bitrate })?
+ .quality
+ }
+}
+
+struct PlexPlaybackQualitySuggestionSessionState: Equatable, Sendable {
+ private(set) var itemKey: String?
+ private(set) var isSuppressed = false
+ private(set) var acceptedQualities: Set = []
+
+ mutating func beginSession(itemKey: String) {
+ self.itemKey = itemKey
+ isSuppressed = false
+ acceptedQualities = []
+ }
+
+ mutating func moveToItem(itemKey: String) {
+ guard self.itemKey != itemKey else {
+ return
+ }
+ beginSession(itemKey: itemKey)
+ }
+
+ mutating func suppress() {
+ isSuppressed = true
+ }
+
+ mutating func recordAccepted(_ quality: PlexVideoQuality) {
+ acceptedQualities.insert(quality)
+ }
+
+ mutating func reset() {
+ itemKey = nil
+ isSuppressed = false
+ acceptedQualities = []
+ }
+}
diff --git a/PlexBar/Playback/PlexPlaybackRequestParameters.swift b/PlexBar/Playback/PlexPlaybackRequestParameters.swift
new file mode 100644
index 0000000..e48c33b
--- /dev/null
+++ b/PlexBar/Playback/PlexPlaybackRequestParameters.swift
@@ -0,0 +1,310 @@
+import PlexModels
+import Foundation
+
+enum PlexSubtitleBurnMode: String, CaseIterable, Identifiable, Sendable {
+ case automatic
+ case always
+ case imageFormatsOnly
+
+ var id: Self { self }
+
+ var label: String {
+ switch self {
+ case .automatic:
+ "Automatic"
+ case .always:
+ "Always"
+ case .imageFormatsOnly:
+ "Only Image Formats"
+ }
+ }
+
+ var explanation: String {
+ switch self {
+ case .automatic:
+ "Burn image-based and complex styled subtitles when needed."
+ case .always:
+ "Burn the selected subtitle into the video."
+ case .imageFormatsOnly:
+ "Burn image-based subtitles and convert advanced text subtitles when needed."
+ }
+ }
+
+ fileprivate var requestValues: (subtitles: String, advancedSubtitles: String) {
+ switch self {
+ case .automatic:
+ (subtitles: "auto", advancedSubtitles: "burn")
+ case .always:
+ (subtitles: "burn", advancedSubtitles: "burn")
+ case .imageFormatsOnly:
+ (subtitles: "auto", advancedSubtitles: "text")
+ }
+ }
+}
+
+enum PlexSubtitleSize: Int, CaseIterable, Identifiable, Sendable {
+ case tiny = 60
+ case small = 80
+ case normal = 100
+ case large = 120
+ case huge = 150
+
+ var id: Self { self }
+
+ var label: String {
+ switch self {
+ case .tiny: "Tiny"
+ case .small: "Small"
+ case .normal: "Normal"
+ case .large: "Large"
+ case .huge: "Huge"
+ }
+ }
+
+ var percentageLabel: String {
+ "\(rawValue)%"
+ }
+}
+
+struct PlexPlaybackRequestParameters {
+ let item: PlexMediaItem
+ let source: PlexPlaybackSource
+ let videoQuality: PlexVideoQuality
+ let musicQuality: PlexMusicQuality
+ let audioBoost: PlexAudioBoost
+ let streamingPolicy: PlexPlaybackStreamingPolicy
+ let subtitleBurnMode: PlexSubtitleBurnMode
+ let subtitleSize: PlexSubtitleSize
+ let automaticallySyncSubtitles: Bool
+ let automaticallyAdjustVideoQuality: Bool
+ let playSmallerVideosAtOriginalQuality: Bool
+ let forceVideoTranscode: Bool
+ let sessionIdentifier: String
+ let startTime: TimeInterval
+ let forceServerMediaSelection: Bool
+
+ init(
+ item: PlexMediaItem,
+ source: PlexPlaybackSource,
+ videoQuality: PlexVideoQuality,
+ musicQuality: PlexMusicQuality = .original,
+ audioBoost: PlexAudioBoost = .none,
+ streamingPolicy: PlexPlaybackStreamingPolicy,
+ subtitleBurnMode: PlexSubtitleBurnMode = .automatic,
+ subtitleSize: PlexSubtitleSize = .normal,
+ automaticallySyncSubtitles: Bool = true,
+ automaticallyAdjustVideoQuality: Bool = false,
+ playSmallerVideosAtOriginalQuality: Bool = true,
+ forceVideoTranscode: Bool = false,
+ sessionIdentifier: String,
+ startTime: TimeInterval,
+ forceServerMediaSelection: Bool
+ ) {
+ self.item = item
+ self.source = source
+ self.videoQuality = videoQuality
+ self.musicQuality = musicQuality
+ self.audioBoost = audioBoost
+ self.streamingPolicy = streamingPolicy
+ self.subtitleBurnMode = subtitleBurnMode
+ self.subtitleSize = subtitleSize
+ self.automaticallySyncSubtitles = automaticallySyncSubtitles
+ self.automaticallyAdjustVideoQuality = automaticallyAdjustVideoQuality
+ self.playSmallerVideosAtOriginalQuality = playSmallerVideosAtOriginalQuality
+ self.forceVideoTranscode = forceVideoTranscode
+ self.sessionIdentifier = sessionIdentifier
+ self.startTime = startTime
+ self.forceServerMediaSelection = forceServerMediaSelection
+ }
+
+ var permitsDirectPlay: Bool {
+ streamingPolicy.allowsDirectPlay
+ && !videoQuality.limits(media: item.media[source.mediaIndex])
+ && !musicQuality.limits(media: item.media[source.mediaIndex])
+ && permitsOriginalVideoQuality
+ && !forcesVideoTranscode
+ && !forceServerMediaSelection
+ && !(automaticallySyncSubtitles && supportsSubtitleAutoSync)
+ }
+
+ var hasMultichannelAudioSource: Bool {
+ let media = item.media[source.mediaIndex]
+ let parts: ArraySlice
+ if source.partIndex >= 0, media.parts.indices.contains(source.partIndex) {
+ parts = media.parts[source.partIndex...source.partIndex]
+ } else {
+ parts = media.parts[...]
+ }
+ guard !parts.isEmpty else { return false }
+
+ return parts.allSatisfy { part in
+ part.streams.contains { stream in
+ stream.streamType == 2
+ && stream.selected == true
+ && (stream.channels ?? 0) > 2
+ }
+ }
+ }
+
+ var supportsSubtitleAutoSync: Bool {
+ let media = item.media[source.mediaIndex]
+ let parts: ArraySlice
+ if source.partIndex >= 0, media.parts.indices.contains(source.partIndex) {
+ parts = media.parts[source.partIndex...source.partIndex]
+ } else {
+ parts = media.parts[...]
+ }
+ guard !parts.isEmpty else { return false }
+
+ return parts.allSatisfy { part in
+ part.streams.contains { stream in
+ stream.streamType == 3
+ && stream.selected == true
+ && stream.canAutoSync == true
+ }
+ }
+ }
+
+ var queryItems: [URLQueryItem] {
+ let media = item.media[source.mediaIndex]
+ let isVideo = media.videoCodec?.nilIfBlank != nil
+ let limitsVideoQuality = videoQuality.limits(media: media)
+ let limitsMusicQuality = musicQuality.limits(media: media)
+ let allowsDirectStream = streamingPolicy.allowsDirectStream
+ && !limitsVideoQuality
+ && !limitsMusicQuality
+ && permitsOriginalVideoQuality
+ && !forcesVideoTranscode
+ let allowsDirectStreamAudio = streamingPolicy.allowsDirectStream
+ && !limitsMusicQuality
+ && (!isVideo || !automaticallyAdjustVideoQuality || allowsDirectStream)
+ let subtitleValues = subtitleBurnMode.requestValues
+ var items = [
+ URLQueryItem(name: "path", value: "/library/metadata/\(item.ratingKey)"),
+ URLQueryItem(name: "mediaIndex", value: String(source.mediaIndex)),
+ URLQueryItem(name: "partIndex", value: String(source.partIndex)),
+ URLQueryItem(name: "protocol", value: "hls"),
+ URLQueryItem(name: "directPlay", value: permitsDirectPlay ? "1" : "0"),
+ URLQueryItem(name: "directStream", value: allowsDirectStream ? "1" : "0"),
+ URLQueryItem(
+ name: "directStreamAudio",
+ value: allowsDirectStreamAudio ? "1" : "0"
+ ),
+ URLQueryItem(name: "subtitles", value: subtitleValues.subtitles),
+ URLQueryItem(
+ name: "advancedSubtitles",
+ value: subtitleValues.advancedSubtitles
+ ),
+ URLQueryItem(name: "offset", value: startTime.plexServerOffset),
+ URLQueryItem(name: "session", value: sessionIdentifier)
+ ]
+
+ if media.videoCodec?.nilIfBlank != nil {
+ items.append(URLQueryItem(
+ name: "subtitleSize",
+ value: String(subtitleSize.rawValue)
+ ))
+ items.append(URLQueryItem(
+ name: "autoAdjustSubtitle",
+ value: automaticallySyncSubtitles && supportsSubtitleAutoSync ? "1" : "0"
+ ))
+ items.append(URLQueryItem(
+ name: "audioBoost",
+ value: String(audioBoost.rawValue)
+ ))
+ items.append(URLQueryItem(
+ name: "autoAdjustQuality",
+ value: automaticallyAdjustVideoQuality ? "1" : "0"
+ ))
+ appendVideoQualityItems(to: &items, media: media)
+ } else if media.audioCodec?.nilIfBlank != nil {
+ let bitrate = musicQuality.bitrate ?? media.bitrate
+ if let bitrate, bitrate > 0 {
+ items.append(URLQueryItem(name: "musicBitrate", value: String(bitrate)))
+ }
+ }
+
+ return items
+ }
+
+ private var permitsOriginalVideoQuality: Bool {
+ let media = item.media[source.mediaIndex]
+ guard media.videoCodec?.nilIfBlank != nil, videoQuality != .original else {
+ return true
+ }
+ return playSmallerVideosAtOriginalQuality
+ }
+
+ private var forcesVideoTranscode: Bool {
+ forceVideoTranscode
+ && item.media[source.mediaIndex].videoCodec?.nilIfBlank != nil
+ }
+
+ private func appendVideoQualityItems(
+ to items: inout [URLQueryItem],
+ media: PlexMediaVersion
+ ) {
+ items.append(URLQueryItem(name: "videoQuality", value: "99"))
+ let resolution = videoQuality.constraints.map { ($0.width, $0.height) }
+ ?? sourceResolution(for: media)
+ // A source's average bitrate is not a playback bandwidth limit. Sending
+ // it as a conversion target can make PMS downscale even at Original.
+ let bitrate = videoQuality.constraints?.bitrate
+
+ if let resolution {
+ items.append(
+ URLQueryItem(
+ name: "videoResolution",
+ value: "\(resolution.0)x\(resolution.1)"
+ )
+ )
+ }
+ if let bitrate, bitrate > 0 {
+ items.append(URLQueryItem(name: "videoBitrate", value: String(bitrate)))
+ }
+ }
+
+ private func sourceResolution(for media: PlexMediaVersion) -> (Int, Int)? {
+ guard let width = media.width, width > 0,
+ let height = media.height, height > 0 else {
+ return nil
+ }
+
+ return (width, height)
+ }
+}
+
+private extension PlexVideoQuality {
+ func limits(media: PlexMediaVersion) -> Bool {
+ guard media.videoCodec?.nilIfBlank != nil, let constraints else {
+ return false
+ }
+ guard let width = media.width, width > 0,
+ let height = media.height, height > 0,
+ let bitrate = media.bitrate, bitrate > 0 else {
+ return true
+ }
+ return width > constraints.width
+ || height > constraints.height
+ || bitrate > constraints.bitrate
+ }
+}
+
+private extension TimeInterval {
+ var plexServerOffset: String {
+ let milliseconds = (max(isFinite ? self : 0, 0) * 1_000).rounded()
+ guard milliseconds.truncatingRemainder(dividingBy: 1_000) != 0 else {
+ return String(Int(milliseconds / 1_000))
+ }
+
+ var value = String(
+ format: "%.3f",
+ locale: Locale(identifier: "en_US_POSIX"),
+ milliseconds / 1_000
+ )
+ while value.last == "0" {
+ value.removeLast()
+ }
+ return value
+ }
+}
diff --git a/PlexBar/Playback/PlexPlayerCoordinator.swift b/PlexBar/Playback/PlexPlayerCoordinator.swift
new file mode 100644
index 0000000..b0be738
--- /dev/null
+++ b/PlexBar/Playback/PlexPlayerCoordinator.swift
@@ -0,0 +1,722 @@
+import PlexModels
+import Foundation
+import Observation
+
+struct PlexCurrentPlayback: Equatable, Sendable {
+ let item: PlexMediaItem
+ let serverIdentifier: String?
+
+ init(presentation: PlexPlaybackPresentation) {
+ item = presentation.item
+ serverIdentifier = presentation.serverIdentifier?.nilIfBlank
+ }
+
+ func belongs(to serverIdentifier: String?) -> Bool {
+ guard let ownServerIdentifier = self.serverIdentifier,
+ let serverIdentifier = serverIdentifier?.nilIfBlank else {
+ return false
+ }
+ return ownServerIdentifier == serverIdentifier
+ }
+}
+
+@MainActor
+@Observable
+final class PlexPlayerCoordinator {
+ var presentation: PlexPlaybackPresentation?
+ private(set) var currentPlayback: PlexCurrentPlayback?
+ private(set) var canGoPrevious = false
+ private(set) var canGoNext = false
+ private(set) var transportAction: PlexPlaybackTransportAction?
+ private(set) var canStop = false
+ private(set) var playbackStatus: PlexPlaybackStatus = .idle
+ private(set) var canSeek = false
+ private(set) var canChangePlaybackRate = false
+ private(set) var playbackRate: PlexPlaybackRate = .normal
+ private(set) var videoQualitySelection = PlexVideoQualitySelection(
+ selectedQuality: .original,
+ isVideo: false,
+ canChange: false
+ )
+ private(set) var serverManagedMediaSelection = PlexServerManagedMediaSelection()
+ private(set) var canChangeServerManagedMediaSelection = false
+ private(set) var canChangeShuffle = false
+ private(set) var isShuffled = false
+ private(set) var canChangeRepeatMode = false
+ private(set) var canRepeatAll = false
+ private(set) var repeatMode: PlexPlaybackRepeatMode = .off
+ private(set) var canAddItemsToQueue = false
+ private(set) var isAddingToQueue = false
+
+ @ObservationIgnored private var previousAction: (@MainActor () -> Void)?
+ @ObservationIgnored private var nextAction: (@MainActor () -> Void)?
+ @ObservationIgnored private var togglePlaybackAction: (@MainActor () -> Void)?
+ @ObservationIgnored private var stopAction: (@MainActor () -> Void)?
+ @ObservationIgnored private var seekAction: (@MainActor (TimeInterval) -> Void)?
+ @ObservationIgnored private var playbackRateAction: (@MainActor (PlexPlaybackRate) -> Void)?
+ @ObservationIgnored private var videoQualityAction: (@MainActor (PlexVideoQuality) -> Void)?
+ @ObservationIgnored private var audioStreamAction: (@MainActor (Int) -> Void)?
+ @ObservationIgnored private var subtitleStreamAction: (@MainActor (Int?) -> Void)?
+ @ObservationIgnored private var shuffleAction: (@MainActor (Bool) -> Void)?
+ @ObservationIgnored private var repeatModeAction: (@MainActor (PlexPlaybackRepeatMode) -> Void)?
+ @ObservationIgnored private var activeSession: PlexPlayerSessionModel?
+ @ObservationIgnored private let userInteractionStore: PlexUserInteractionStore
+ @ObservationIgnored private let bandwidthRegistry: PlexPlaybackBandwidthRegistry
+
+ init(
+ userInteractionStore: PlexUserInteractionStore = PlexUserInteractionStore(),
+ bandwidthRegistry: PlexPlaybackBandwidthRegistry = PlexPlaybackBandwidthRegistry()
+ ) {
+ self.userInteractionStore = userInteractionStore
+ self.bandwidthRegistry = bandwidthRegistry
+ }
+
+ func present(_ presentation: PlexPlaybackPresentation) {
+ if self.presentation?.id != presentation.id {
+ stopActiveSession(deactivateNowPlaying: false)
+ clearPlaybackControls()
+ }
+ self.presentation = presentation
+ publishCurrentPlayback(presentation)
+ }
+
+ func clear() {
+ stopActiveSession(deactivateNowPlaying: true)
+ presentation = nil
+ currentPlayback = nil
+ clearPlaybackControls()
+ }
+
+ func session(
+ for presentation: PlexPlaybackPresentation,
+ browserStore: PlexBrowserStore,
+ downloadsStore: PlexDownloadsStore? = nil
+ ) -> PlexPlayerSessionModel {
+ session(
+ for: presentation,
+ browserStore: browserStore,
+ settingsStore: browserStore.connectionStore.settings,
+ downloadsStore: downloadsStore
+ )
+ }
+
+ func session(
+ for presentation: PlexPlaybackPresentation,
+ browserStore: PlexBrowserStore,
+ settingsStore: PlexSettingsStore,
+ downloadsStore: PlexDownloadsStore? = nil
+ ) -> PlexPlayerSessionModel {
+ if let activeSession {
+ return activeSession
+ }
+
+ let session = PlexPlayerSessionModel(
+ presentation: presentation,
+ browserStore: browserStore,
+ settingsStore: settingsStore,
+ userInteractionStore: userInteractionStore,
+ coordinator: self,
+ downloadsStore: downloadsStore,
+ bandwidthRegistry: bandwidthRegistry
+ )
+ activeSession = session
+ return session
+ }
+
+ func close(_ session: PlexPlayerSessionModel) {
+ guard activeSession === session else {
+ session.stop(deactivateNowPlaying: false)
+ return
+ }
+
+ activeSession = nil
+ presentation = nil
+ currentPlayback = nil
+ clearPlaybackControls()
+ session.stop()
+ }
+
+ func isActive(_ session: PlexPlayerSessionModel) -> Bool {
+ activeSession === session
+ }
+
+ func updateCurrentPlayback(
+ for session: PlexPlayerSessionModel,
+ presentation: PlexPlaybackPresentation
+ ) {
+ guard isActive(session) else {
+ return
+ }
+ publishCurrentPlayback(presentation)
+ }
+
+ func installNavigation(
+ for session: PlexPlayerSessionModel,
+ previous: @escaping @MainActor () -> Void,
+ next: @escaping @MainActor () -> Void
+ ) {
+ guard isActive(session) else {
+ return
+ }
+ installNavigation(previous: previous, next: next)
+ }
+
+ func updateNavigation(
+ for session: PlexPlayerSessionModel,
+ canGoPrevious: Bool,
+ canGoNext: Bool
+ ) {
+ guard isActive(session) else {
+ return
+ }
+ updateNavigation(canGoPrevious: canGoPrevious, canGoNext: canGoNext)
+ }
+
+ func clearNavigation(for session: PlexPlayerSessionModel) {
+ guard isActive(session) else {
+ return
+ }
+ clearNavigation()
+ }
+
+ func installTransport(
+ for session: PlexPlayerSessionModel,
+ status: PlexPlaybackStatus,
+ canToggle: Bool,
+ toggle: @escaping @MainActor () -> Void,
+ stop: @escaping @MainActor () -> Void
+ ) {
+ guard isActive(session) else {
+ return
+ }
+ installTransport(
+ status: status,
+ canToggle: canToggle,
+ toggle: toggle,
+ stop: stop
+ )
+ }
+
+ func updateTransport(
+ for session: PlexPlayerSessionModel,
+ status: PlexPlaybackStatus,
+ canToggle: Bool
+ ) {
+ guard isActive(session) else {
+ return
+ }
+ updateTransport(status: status, canToggle: canToggle)
+ }
+
+ func clearTransport(for session: PlexPlayerSessionModel) {
+ guard isActive(session) else {
+ return
+ }
+ clearTransport()
+ }
+
+ func installSeeking(
+ for session: PlexPlayerSessionModel,
+ canSeek: Bool,
+ action: @escaping @MainActor (TimeInterval) -> Void
+ ) {
+ guard isActive(session) else {
+ return
+ }
+ installSeeking(canSeek: canSeek, action: action)
+ }
+
+ func updateSeeking(for session: PlexPlayerSessionModel, canSeek: Bool) {
+ guard isActive(session) else {
+ return
+ }
+ updateSeeking(canSeek: canSeek)
+ }
+
+ func clearSeeking(for session: PlexPlayerSessionModel) {
+ guard isActive(session) else {
+ return
+ }
+ clearSeeking()
+ }
+
+ func installPlaybackRate(
+ for session: PlexPlayerSessionModel,
+ playbackRate: PlexPlaybackRate,
+ action: @escaping @MainActor (PlexPlaybackRate) -> Void
+ ) {
+ guard isActive(session) else {
+ return
+ }
+ installPlaybackRate(playbackRate: playbackRate, action: action)
+ }
+
+ func updatePlaybackRate(
+ for session: PlexPlayerSessionModel,
+ playbackRate: PlexPlaybackRate
+ ) {
+ guard isActive(session) else {
+ return
+ }
+ self.playbackRate = playbackRate
+ }
+
+ func clearPlaybackRate(for session: PlexPlayerSessionModel) {
+ guard isActive(session) else {
+ return
+ }
+ clearPlaybackRate()
+ }
+
+ func installVideoQuality(
+ for session: PlexPlayerSessionModel,
+ selection: PlexVideoQualitySelection,
+ action: @escaping @MainActor (PlexVideoQuality) -> Void
+ ) {
+ guard isActive(session) else {
+ return
+ }
+ installVideoQuality(selection: selection, action: action)
+ }
+
+ func updateVideoQuality(
+ for session: PlexPlayerSessionModel,
+ selection: PlexVideoQualitySelection
+ ) {
+ guard isActive(session) else {
+ return
+ }
+ videoQualitySelection = selection
+ }
+
+ func clearVideoQuality(for session: PlexPlayerSessionModel) {
+ guard isActive(session) else {
+ return
+ }
+ clearVideoQuality()
+ }
+
+ func installServerManagedMediaSelection(
+ for session: PlexPlayerSessionModel,
+ selection: PlexServerManagedMediaSelection,
+ canChange: Bool,
+ selectAudioStream: @escaping @MainActor (Int) -> Void,
+ selectSubtitleStream: @escaping @MainActor (Int?) -> Void
+ ) {
+ guard isActive(session) else {
+ return
+ }
+ installServerManagedMediaSelection(
+ selection: selection,
+ canChange: canChange,
+ selectAudioStream: selectAudioStream,
+ selectSubtitleStream: selectSubtitleStream
+ )
+ }
+
+ func updateServerManagedMediaSelection(
+ for session: PlexPlayerSessionModel,
+ selection: PlexServerManagedMediaSelection,
+ canChange: Bool
+ ) {
+ guard isActive(session) else {
+ return
+ }
+ updateServerManagedMediaSelection(selection: selection, canChange: canChange)
+ }
+
+ func clearServerManagedMediaSelection(for session: PlexPlayerSessionModel) {
+ guard isActive(session) else {
+ return
+ }
+ clearServerManagedMediaSelection()
+ }
+
+ func clearPlaybackControls(for session: PlexPlayerSessionModel) {
+ guard isActive(session) else {
+ return
+ }
+ clearPlaybackControls()
+ }
+
+ func installShuffle(
+ for session: PlexPlayerSessionModel,
+ isShuffled: Bool,
+ canChange: Bool,
+ action: @escaping @MainActor (Bool) -> Void
+ ) {
+ guard isActive(session) else {
+ return
+ }
+ installShuffle(isShuffled: isShuffled, canChange: canChange, action: action)
+ }
+
+ func updateShuffle(
+ for session: PlexPlayerSessionModel,
+ isShuffled: Bool,
+ canChange: Bool
+ ) {
+ guard isActive(session) else {
+ return
+ }
+ updateShuffle(isShuffled: isShuffled, canChange: canChange)
+ }
+
+ func clearShuffle(for session: PlexPlayerSessionModel) {
+ guard isActive(session) else {
+ return
+ }
+ clearShuffle()
+ }
+
+ func installRepeatMode(
+ for session: PlexPlayerSessionModel,
+ repeatMode: PlexPlaybackRepeatMode,
+ canRepeatAll: Bool,
+ action: @escaping @MainActor (PlexPlaybackRepeatMode) -> Void
+ ) {
+ guard isActive(session) else {
+ return
+ }
+ installRepeatMode(
+ repeatMode: repeatMode,
+ canRepeatAll: canRepeatAll,
+ action: action
+ )
+ }
+
+ func updateRepeatMode(
+ for session: PlexPlayerSessionModel,
+ repeatMode: PlexPlaybackRepeatMode,
+ canRepeatAll: Bool
+ ) {
+ guard isActive(session) else {
+ return
+ }
+ updateRepeatMode(repeatMode: repeatMode, canRepeatAll: canRepeatAll)
+ }
+
+ func clearRepeatMode(for session: PlexPlayerSessionModel) {
+ guard isActive(session) else {
+ return
+ }
+ clearRepeatMode()
+ }
+
+ func updateQueueInsertion(
+ for session: PlexPlayerSessionModel,
+ canAdd: Bool,
+ isAdding: Bool
+ ) {
+ guard isActive(session) else {
+ return
+ }
+ canAddItemsToQueue = canAdd
+ isAddingToQueue = isAdding
+ }
+
+ func canAddToQueue(_ item: PlexMediaItem) -> Bool {
+ canAddItemsToQueue && activeSession?.canAddToQueue(item) == true
+ }
+
+ func addToQueue(
+ _ item: PlexMediaItem,
+ insertion: PlexPlayQueueInsertion
+ ) async throws {
+ guard canAddToQueue(item), let activeSession else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+ try await activeSession.addToQueue(item, insertion: insertion)
+ }
+
+ func installNavigation(
+ previous: @escaping @MainActor () -> Void,
+ next: @escaping @MainActor () -> Void
+ ) {
+ previousAction = previous
+ nextAction = next
+ }
+
+ func updateNavigation(canGoPrevious: Bool, canGoNext: Bool) {
+ self.canGoPrevious = canGoPrevious
+ self.canGoNext = canGoNext
+ }
+
+ func goPrevious() {
+ guard canGoPrevious else {
+ return
+ }
+ previousAction?()
+ }
+
+ func goNext() {
+ guard canGoNext else {
+ return
+ }
+ nextAction?()
+ }
+
+ func togglePlayback() {
+ guard transportAction != nil else {
+ return
+ }
+ togglePlaybackAction?()
+ }
+
+ func stopPlayback() {
+ guard canStop else {
+ return
+ }
+ stopAction?()
+ }
+
+ func skipBackward() {
+ skip(.backward)
+ }
+
+ func skipForward() {
+ skip(.forward)
+ }
+
+ func selectPlaybackRate(_ playbackRate: PlexPlaybackRate) {
+ guard canChangePlaybackRate else {
+ return
+ }
+ playbackRateAction?(playbackRate)
+ }
+
+ func selectVideoQuality(_ videoQuality: PlexVideoQuality) {
+ guard videoQualitySelection.canSelect(videoQuality) else {
+ return
+ }
+ videoQualityAction?(videoQuality)
+ }
+
+ func selectAudioStream(_ streamID: Int) {
+ guard canChangeServerManagedMediaSelection,
+ serverManagedMediaSelection.canSelectAudioStream(streamID) else {
+ return
+ }
+ audioStreamAction?(streamID)
+ }
+
+ func selectSubtitleStream(_ streamID: Int?) {
+ guard canChangeServerManagedMediaSelection,
+ serverManagedMediaSelection.canSelectSubtitleStream(streamID) else {
+ return
+ }
+ subtitleStreamAction?(streamID)
+ }
+
+ func setShuffled(_ isShuffled: Bool) {
+ guard canChangeShuffle, self.isShuffled != isShuffled else {
+ return
+ }
+ shuffleAction?(isShuffled)
+ }
+
+ func selectRepeatMode(_ repeatMode: PlexPlaybackRepeatMode) {
+ guard canChangeRepeatMode,
+ repeatMode != self.repeatMode,
+ repeatMode != .all || canRepeatAll else {
+ return
+ }
+ repeatModeAction?(repeatMode)
+ }
+
+ func clearNavigation() {
+ canGoPrevious = false
+ canGoNext = false
+ previousAction = nil
+ nextAction = nil
+ }
+
+ func installTransport(
+ status: PlexPlaybackStatus,
+ canToggle: Bool = true,
+ toggle: @escaping @MainActor () -> Void,
+ stop: @escaping @MainActor () -> Void
+ ) {
+ togglePlaybackAction = toggle
+ stopAction = stop
+ canStop = true
+ updateTransport(status: status, canToggle: canToggle)
+ }
+
+ func updateTransport(status: PlexPlaybackStatus, canToggle: Bool = true) {
+ playbackStatus = status
+ transportAction = canToggle ? PlexPlaybackTransportAction(status: status) : nil
+ }
+
+ func clearTransport() {
+ playbackStatus = .idle
+ transportAction = nil
+ canStop = false
+ togglePlaybackAction = nil
+ stopAction = nil
+ }
+
+ func installSeeking(
+ canSeek: Bool,
+ action: @escaping @MainActor (TimeInterval) -> Void
+ ) {
+ self.canSeek = canSeek
+ seekAction = action
+ }
+
+ func updateSeeking(canSeek: Bool) {
+ self.canSeek = canSeek
+ }
+
+ func clearSeeking() {
+ canSeek = false
+ seekAction = nil
+ }
+
+ func installPlaybackRate(
+ playbackRate: PlexPlaybackRate,
+ action: @escaping @MainActor (PlexPlaybackRate) -> Void
+ ) {
+ self.playbackRate = playbackRate
+ playbackRateAction = action
+ canChangePlaybackRate = true
+ }
+
+ func clearPlaybackRate() {
+ playbackRate = .normal
+ playbackRateAction = nil
+ canChangePlaybackRate = false
+ }
+
+ func installVideoQuality(
+ selection: PlexVideoQualitySelection,
+ action: @escaping @MainActor (PlexVideoQuality) -> Void
+ ) {
+ videoQualitySelection = selection
+ videoQualityAction = action
+ }
+
+ func clearVideoQuality() {
+ videoQualitySelection = PlexVideoQualitySelection(
+ selectedQuality: .original,
+ isVideo: false,
+ canChange: false
+ )
+ videoQualityAction = nil
+ }
+
+ func installServerManagedMediaSelection(
+ selection: PlexServerManagedMediaSelection,
+ canChange: Bool,
+ selectAudioStream: @escaping @MainActor (Int) -> Void,
+ selectSubtitleStream: @escaping @MainActor (Int?) -> Void
+ ) {
+ audioStreamAction = selectAudioStream
+ subtitleStreamAction = selectSubtitleStream
+ updateServerManagedMediaSelection(selection: selection, canChange: canChange)
+ }
+
+ func updateServerManagedMediaSelection(
+ selection: PlexServerManagedMediaSelection,
+ canChange: Bool
+ ) {
+ serverManagedMediaSelection = selection
+ canChangeServerManagedMediaSelection = canChange && selection.hasChoices
+ }
+
+ func clearServerManagedMediaSelection() {
+ serverManagedMediaSelection = PlexServerManagedMediaSelection()
+ canChangeServerManagedMediaSelection = false
+ audioStreamAction = nil
+ subtitleStreamAction = nil
+ }
+
+ func installShuffle(
+ isShuffled: Bool,
+ canChange: Bool,
+ action: @escaping @MainActor (Bool) -> Void
+ ) {
+ self.isShuffled = isShuffled
+ canChangeShuffle = canChange
+ shuffleAction = action
+ }
+
+ func updateShuffle(isShuffled: Bool, canChange: Bool) {
+ self.isShuffled = isShuffled
+ canChangeShuffle = canChange
+ }
+
+ func clearShuffle() {
+ isShuffled = false
+ canChangeShuffle = false
+ shuffleAction = nil
+ }
+
+ func installRepeatMode(
+ repeatMode: PlexPlaybackRepeatMode,
+ canRepeatAll: Bool,
+ action: @escaping @MainActor (PlexPlaybackRepeatMode) -> Void
+ ) {
+ self.repeatMode = repeatMode
+ self.canRepeatAll = canRepeatAll
+ repeatModeAction = action
+ canChangeRepeatMode = true
+ }
+
+ func updateRepeatMode(
+ repeatMode: PlexPlaybackRepeatMode,
+ canRepeatAll: Bool
+ ) {
+ self.repeatMode = repeatMode
+ self.canRepeatAll = canRepeatAll
+ }
+
+ func clearRepeatMode() {
+ repeatMode = .off
+ canRepeatAll = false
+ canChangeRepeatMode = false
+ repeatModeAction = nil
+ }
+
+ func clearQueueInsertion() {
+ canAddItemsToQueue = false
+ isAddingToQueue = false
+ }
+
+ private func clearPlaybackControls() {
+ clearTransport()
+ clearNavigation()
+ clearSeeking()
+ clearPlaybackRate()
+ clearVideoQuality()
+ clearServerManagedMediaSelection()
+ clearShuffle()
+ clearRepeatMode()
+ clearQueueInsertion()
+ }
+
+ private func stopActiveSession(deactivateNowPlaying: Bool) {
+ guard let activeSession else {
+ return
+ }
+
+ self.activeSession = nil
+ activeSession.stop(deactivateNowPlaying: deactivateNowPlaying)
+ }
+
+ private func publishCurrentPlayback(_ presentation: PlexPlaybackPresentation) {
+ let currentPlayback = PlexCurrentPlayback(presentation: presentation)
+ guard self.currentPlayback != currentPlayback else {
+ return
+ }
+ self.currentPlayback = currentPlayback
+ }
+
+ private func skip(_ direction: PlexPlaybackSkipDirection) {
+ guard canSeek,
+ let offset = direction.offset(for: PlexPlaybackSeek.skipInterval) else {
+ return
+ }
+ seekAction?(offset)
+ }
+}
diff --git a/PlexBar/Playback/PlexPlayerView.swift b/PlexBar/Playback/PlexPlayerView.swift
new file mode 100644
index 0000000..fa7edce
--- /dev/null
+++ b/PlexBar/Playback/PlexPlayerView.swift
@@ -0,0 +1,3791 @@
+import PlexModels
+import AVKit
+import OSLog
+import SwiftUI
+
+private let plexPlayerLogger = Logger(
+ subsystem: AppConstants.bundleIdentifier,
+ category: "Player"
+)
+
+struct PlexPlaybackPresentation: Identifiable {
+ let item: PlexMediaItem
+ let plan: PlexPlaybackPlan
+ let queue: PlexPlaybackQueue?
+ let videoQuality: PlexVideoQuality
+ let serverIdentifier: String?
+ let queueSourcePreference: PlexPlaybackQueueSourcePreference?
+ let offlinePackageID: UUID?
+
+ init(
+ item: PlexMediaItem,
+ plan: PlexPlaybackPlan,
+ queue: PlexPlaybackQueue?,
+ videoQuality: PlexVideoQuality,
+ serverIdentifier: String? = nil,
+ queueSourcePreference: PlexPlaybackQueueSourcePreference? = nil,
+ offlinePackageID: UUID? = nil
+ ) {
+ self.item = item
+ self.plan = plan
+ self.queue = queue
+ self.videoQuality = videoQuality
+ self.serverIdentifier = serverIdentifier
+ self.queueSourcePreference = queueSourcePreference
+ self.offlinePackageID = offlinePackageID
+ }
+
+ var id: String { plan.sessionIdentifier }
+}
+
+struct PlexPlayerItemMutationTicket: Equatable, Sendable {
+ let ratingKey: String
+ let sessionIdentifier: String
+
+ init(presentation: PlexPlaybackPresentation) {
+ ratingKey = presentation.item.ratingKey
+ sessionIdentifier = presentation.plan.sessionIdentifier
+ }
+
+ func accepts(_ presentation: PlexPlaybackPresentation) -> Bool {
+ presentation.item.ratingKey == ratingKey
+ && presentation.plan.sessionIdentifier == sessionIdentifier
+ }
+}
+
+enum PlexPlayerLibraryHandoffPolicy {
+ static func canOpen(
+ playbackServerIdentifier: String?,
+ browserServerIdentifier: String?
+ ) -> Bool {
+ guard let playbackServerIdentifier = playbackServerIdentifier?.nilIfBlank,
+ let browserServerIdentifier = browserServerIdentifier?.nilIfBlank else {
+ return false
+ }
+ return playbackServerIdentifier == browserServerIdentifier
+ }
+}
+
+enum PlexPlayerNavigationTitle {
+ static let fallback = "Player"
+
+ static func resolve(_ mediaTitle: String?) -> String {
+ mediaTitle?.nilIfBlank ?? fallback
+ }
+}
+
+@MainActor
+@Observable
+final class PlexPlayerPresentationLifecycle {
+ private(set) var isFullScreenActive = false
+ private(set) var isFullScreenTransitioning = false
+ private(set) var isPictureInPictureActive = false
+
+ var keepsPlaybackAliveWhenViewDisappears: Bool {
+ isFullScreenActive || isPictureInPictureActive
+ }
+
+ func willEnterFullScreen() {
+ isFullScreenActive = true
+ isFullScreenTransitioning = true
+ }
+
+ func didEnterFullScreen() {
+ isFullScreenTransitioning = false
+ }
+
+ func willExitFullScreen() {
+ isFullScreenTransitioning = true
+ }
+
+ func didExitFullScreen() {
+ isFullScreenActive = false
+ isFullScreenTransitioning = false
+ }
+
+ func willStartPictureInPicture() {
+ isPictureInPictureActive = true
+ }
+
+ func failedToStartPictureInPicture() {
+ isPictureInPictureActive = false
+ }
+
+ func didStopPictureInPicture() {
+ isPictureInPictureActive = false
+ }
+}
+
+struct PlexPlayerView: View {
+ @Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion
+ @Environment(\.colorSchemeContrast) private var colorSchemeContrast
+ @Environment(\.openWindow) private var openWindow
+ @State private var session: PlexPlayerSessionModel
+ @State private var presentationLifecycle: PlexPlayerPresentationLifecycle
+ @State private var overlaySelection = PlexPlayerOverlaySelection()
+ @State private var isPlaybackEndedOverlayDismissed = false
+ private let coordinator: PlexPlayerCoordinator
+ private let settingsStore: PlexSettingsStore
+
+ init(
+ presentation: PlexPlaybackPresentation,
+ browserStore: PlexBrowserStore,
+ settingsStore: PlexSettingsStore,
+ coordinator: PlexPlayerCoordinator,
+ downloadsStore: PlexDownloadsStore
+ ) {
+ self.coordinator = coordinator
+ self.settingsStore = settingsStore
+ _presentationLifecycle = State(initialValue: PlexPlayerPresentationLifecycle())
+ _session = State(initialValue: coordinator.session(
+ for: presentation,
+ browserStore: browserStore,
+ settingsStore: settingsStore,
+ downloadsStore: downloadsStore
+ ))
+ }
+
+ var body: some View {
+ PlexPlayerStage {
+ ZStack {
+ PlexAVPlayerView(
+ player: session.engine.player,
+ playbackRate: session.engine.playbackRate,
+ audioOverlay: audioOverlay,
+ mediaSelection: session.mediaSelection,
+ videoQualitySelection: session.videoQualitySelection,
+ videoDynamicRange: settingsStore.videoDynamicRange,
+ videoScalingMode: settingsStore.videoScalingMode,
+ canChangeMediaSelection: session.canChangeMediaSelection,
+ nativeMediaSelectionGeneration: session.nativeMediaSelectionGeneration,
+ markerAction: session.activeMarkerAction,
+ presentationLifecycle: presentationLifecycle,
+ restorePlayerInterface: restorePlayerInterface,
+ onSelectPlaybackRate: session.selectPlaybackRate,
+ onSelectAudioStream: session.selectAudioStream,
+ onSelectSubtitleStream: session.selectSubtitleStream,
+ onSelectVideoQuality: session.selectVideoQuality,
+ onSelectVideoDynamicRange: selectVideoDynamicRange,
+ onSelectVideoScalingMode: selectVideoScalingMode,
+ onUpdateNativeMediaSelectionAvailability: session.updateNativeMediaSelectionAvailability,
+ onSkipMarker: session.skipActiveMarker
+ )
+
+ if session.showsVideoPreparationStage {
+ PlexVideoPreparationStage(
+ item: session.presentation.item,
+ serverURL: session.artworkServerURL,
+ token: session.artworkToken,
+ clientContext: session.artworkClientContext
+ )
+ .transition(.opacity)
+ }
+
+ if session.hasPostPlayPresentation,
+ session.engine.status == .ended,
+ !isPlaybackEndedOverlayDismissed {
+ PlexPlaybackEndedOverlay(
+ session: session,
+ dismiss: { isPlaybackEndedOverlayDismissed = true }
+ )
+ .transition(.opacity.combined(with: .scale(scale: 0.98)))
+ }
+
+ if let overlay = overlaySelection.selected {
+ Color.black.opacity(overlayStyle.backgroundScrimOpacity)
+ .ignoresSafeArea()
+ .contentShape(Rectangle())
+ .onTapGesture { overlaySelection.dismiss() }
+ .transition(.opacity)
+
+ PlexPlayerHUD(
+ title: overlay.label,
+ systemImage: overlay.systemImage,
+ dismiss: { overlaySelection.dismiss() }
+ ) {
+ switch overlay {
+ case .info:
+ PlexPlayerPlaybackInfoHUD(session: session)
+ case .upNext:
+ PlexUpNextHUD(session: session)
+ }
+ }
+ .padding(24)
+ .transition(.opacity.combined(with: .scale(scale: 0.96)))
+ .zIndex(1)
+ }
+ }
+ .animation(
+ accessibilityReduceMotion ? nil : .easeOut(duration: 0.2),
+ value: session.showsVideoPreparationStage
+ )
+ .animation(
+ accessibilityReduceMotion ? nil : .easeOut(duration: 0.2),
+ value: session.hasPostPlayPresentation && !isPlaybackEndedOverlayDismissed
+ )
+ .animation(
+ accessibilityReduceMotion ? nil : .snappy(duration: 0.22),
+ value: overlaySelection.selected
+ )
+ }
+ .alert(
+ "Change Video Quality?",
+ isPresented: Binding(
+ get: { session.qualitySuggestion != nil },
+ set: { isPresented in
+ if !isPresented {
+ session.dismissQualitySuggestion()
+ }
+ }
+ ),
+ presenting: session.qualitySuggestion
+ ) { suggestion in
+ Button("Change to \(suggestion.targetQuality.label)") {
+ session.acceptQualitySuggestion()
+ }
+ Button("Keep Current", role: .cancel) {
+ session.dismissQualitySuggestion()
+ }
+ } message: { suggestion in
+ Text(suggestion.message)
+ }
+ .frame(minWidth: 760, minHeight: 500)
+ .navigationTitle(PlexPlayerNavigationTitle.resolve(session.presentation.item.title))
+ .focusedSceneValue(\.plexPlayerSurfaceIsFocused, true)
+ .focusedSceneValue(
+ \.plexPlayerInfoCommand,
+ PlexFocusedCommandAction(
+ title: overlaySelection.commandTitle(for: .info),
+ perform: togglePlaybackInfoOverlay
+ )
+ )
+ .focusedSceneValue(
+ \.plexPlayerUpNextCommand,
+ PlexFocusedCommandAction(
+ title: overlaySelection.commandTitle(for: .upNext),
+ perform: toggleUpNextOverlay
+ )
+ )
+ .toolbar {
+ ToolbarItem(placement: .navigation) {
+ Button("Back to Library", systemImage: "chevron.backward", action: closePlayer)
+ .help("Stop Playback and Return to Library")
+ .keyboardShortcut(presentationLifecycle.isFullScreenActive ? nil : .cancelAction)
+ }
+
+ ToolbarItem(placement: .automatic) {
+ PlexPlaybackOptionsMenu(
+ session: session,
+ settingsStore: settingsStore
+ )
+ }
+
+ ToolbarItem(placement: .automatic) {
+ PlexPlaybackRoutePicker(player: session.engine.player)
+ .frame(width: 28, height: 28)
+ .help("Choose Playback Destination")
+ }
+
+ ToolbarItem(placement: .automatic) {
+ Button("Playback Info", systemImage: "info.circle", action: togglePlaybackInfoOverlay)
+ .help(
+ overlaySelection.selected == .info
+ ? "Hide Playback Info"
+ : "Show Playback Info"
+ )
+ .accessibilityValue(
+ overlaySelection.selected == .info ? "Shown" : "Hidden"
+ )
+ }
+
+ ToolbarItem(placement: .automatic) {
+ Button("Up Next", systemImage: "list.bullet", action: toggleUpNextOverlay)
+ .help(
+ overlaySelection.selected == .upNext
+ ? "Hide Up Next"
+ : "Show Up Next"
+ )
+ .accessibilityValue(
+ overlaySelection.selected == .upNext ? "Shown" : "Hidden"
+ )
+ }
+ }
+ .task {
+ await session.start()
+ }
+ .onChange(of: session.engine.status, initial: true) {
+ session.playbackStatusDidChange()
+ }
+ .onChange(of: session.engine.metricFacts) {
+ session.playbackMetricsDidChange()
+ }
+ .onChange(of: settingsStore.qualitySuggestionsEnabled) {
+ session.qualitySuggestionSettingsDidChange()
+ }
+ .onChange(of: session.engine.position, initial: true) {
+ session.playbackPositionDidChange()
+ }
+ .onChange(of: settingsStore.playbackMarkerPreferences) {
+ session.playbackMarkerPreferencesDidChange()
+ }
+ .onChange(of: session.engine.unexpectedTimeJumpRevision) {
+ session.playbackTimeDidJump()
+ }
+ .onChange(of: session.hasPostPlayPresentation, initial: true) {
+ if !session.hasPostPlayPresentation {
+ isPlaybackEndedOverlayDismissed = false
+ }
+ }
+ .onChange(of: session.presentation.item.ratingKey) {
+ overlaySelection.dismiss()
+ isPlaybackEndedOverlayDismissed = false
+ }
+ .onDisappear {
+ handleViewDisappearance()
+ }
+ .alert(
+ "Playback Error",
+ isPresented: Binding(
+ get: { session.errorMessage != nil },
+ set: { isPresented in
+ if !isPresented {
+ session.errorMessage = nil
+ }
+ }
+ )
+ ) {
+ if session.canRetryPlayback {
+ Button("Retry") {
+ session.retryPlayback()
+ }
+ }
+ Button("Close", role: .cancel, action: closePlayer)
+ } message: {
+ Text(session.errorMessage ?? "Unknown playback error.")
+ }
+ }
+
+ private func selectVideoDynamicRange(_ dynamicRange: PlexVideoDisplayDynamicRange) {
+ guard settingsStore.videoDynamicRange != dynamicRange else {
+ return
+ }
+ settingsStore.videoDynamicRange = dynamicRange
+ }
+
+ private func selectVideoScalingMode(_ scalingMode: PlexVideoScalingMode) {
+ guard settingsStore.videoScalingMode != scalingMode else {
+ return
+ }
+ settingsStore.videoScalingMode = scalingMode
+ }
+
+ private func togglePlaybackInfoOverlay() {
+ overlaySelection.toggle(.info)
+ }
+
+ private func toggleUpNextOverlay() {
+ overlaySelection.toggle(.upNext)
+ }
+
+ private var audioOverlay: PlexAudioPlayerOverlay? {
+ guard let presentation = PlexAudioPlaybackPresentation(
+ item: session.presentation.item,
+ source: session.presentation.plan.source
+ ) else {
+ return nil
+ }
+
+ return PlexAudioPlayerOverlay(
+ presentation: presentation,
+ serverURL: session.artworkServerURL,
+ token: session.artworkToken,
+ clientContext: session.artworkClientContext
+ )
+ }
+
+ private func closePlayer() {
+ coordinator.close(session)
+ }
+
+ private func handleViewDisappearance() {
+ guard coordinator.isActive(session),
+ !presentationLifecycle.keepsPlaybackAliveWhenViewDisappears else {
+ return
+ }
+
+ closePlayer()
+ }
+
+ private func restorePlayerInterface(completion: @escaping (Bool) -> Void) {
+ openWindow(id: PlexMainNavigationStore.windowID)
+ completion(true)
+ }
+
+ private var overlayStyle: PlexPlayerOverlayStyle {
+ PlexPlayerOverlayStyle(contrast: colorSchemeContrast)
+ }
+}
+
+struct PlexPlayerOverlayStyle: Equatable {
+ let backgroundScrimOpacity: Double
+
+ init(contrast: ColorSchemeContrast) {
+ backgroundScrimOpacity = contrast == .increased ? 0.24 : 0.14
+ }
+}
+
+enum PlexPlayerOverlay: Equatable, Sendable {
+ case info
+ case upNext
+}
+
+struct PlexPlayerOverlaySelection: Equatable, Sendable {
+ private(set) var selected: PlexPlayerOverlay?
+
+ var isPresented: Bool {
+ selected != nil
+ }
+
+ func commandTitle(for overlay: PlexPlayerOverlay) -> String {
+ selected == overlay ? "Hide \(overlay.label)" : "Show \(overlay.label)"
+ }
+
+ mutating func toggle(_ overlay: PlexPlayerOverlay) {
+ selected = selected == overlay ? nil : overlay
+ }
+
+ mutating func dismiss() {
+ selected = nil
+ }
+
+ mutating func present(_ overlay: PlexPlayerOverlay) {
+ selected = overlay
+ }
+}
+
+extension PlexPlayerOverlay {
+ var label: String {
+ switch self {
+ case .info: "Playback Info"
+ case .upNext: "Up Next"
+ }
+ }
+
+ var systemImage: String {
+ switch self {
+ case .info: "info.circle"
+ case .upNext: "list.bullet"
+ }
+ }
+}
+
+struct PlexPlayerStage: View {
+ private let content: Content
+
+ init(@ViewBuilder content: () -> Content) {
+ self.content = content()
+ }
+
+ var body: some View {
+ ZStack {
+ Color.black
+ content
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .layoutPriority(1)
+ }
+}
+
+enum PlexNativePlayerSizing {
+ static func exactSize(for proposal: ProposedViewSize) -> CGSize? {
+ guard let width = proposal.width,
+ let height = proposal.height,
+ width.isFinite,
+ height.isFinite,
+ width > 0,
+ height > 0 else {
+ return nil
+ }
+
+ return CGSize(width: width, height: height)
+ }
+}
+
+@MainActor
+enum PlexPlaybackRoutePickerConfiguration {
+ static func apply(
+ to routePickerView: AVRoutePickerView,
+ player: AVPlayer
+ ) {
+ if routePickerView.player !== player {
+ routePickerView.player = player
+ }
+ }
+}
+
+private struct PlexPlaybackOptionsMenu: View {
+ let session: PlexPlayerSessionModel
+ let settingsStore: PlexSettingsStore
+
+ var body: some View {
+ Menu {
+ if session.videoQualitySelection.isVideo {
+ videoQualityMenu
+ videoDynamicRangeMenu
+ videoScalingMenu
+ Divider()
+ }
+
+ playbackSpeedMenu
+
+ if session.serverManagedMediaSelection.hasChoices {
+ Divider()
+ serverManagedMediaSelectionMenus
+ }
+ } label: {
+ Label("Playback Options", systemImage: "gearshape")
+ }
+ .menuStyle(.button)
+ .fixedSize()
+ .help("Choose Playback Options")
+ .accessibilityLabel("Playback Options")
+ }
+
+ private var videoQualityMenu: some View {
+ Menu("Video Quality") {
+ ForEach(PlexVideoQuality.allCases) { quality in
+ Button {
+ session.selectVideoQuality(quality)
+ } label: {
+ if session.videoQualitySelection.selectedQuality == quality {
+ Label(quality.label, systemImage: "checkmark")
+ } else {
+ Text(quality.label)
+ }
+ }
+ .disabled(!session.videoQualitySelection.canSelect(quality))
+ }
+ }
+ }
+
+ private var videoDynamicRangeMenu: some View {
+ Menu("Video Dynamic Range") {
+ ForEach(PlexVideoDisplayDynamicRange.allCases) { dynamicRange in
+ Button {
+ settingsStore.videoDynamicRange = dynamicRange
+ } label: {
+ if settingsStore.videoDynamicRange == dynamicRange {
+ Label(dynamicRange.label, systemImage: "checkmark")
+ } else {
+ Text(dynamicRange.label)
+ }
+ }
+ }
+ }
+ }
+
+ private var videoScalingMenu: some View {
+ Menu("Video Scaling") {
+ ForEach(PlexVideoScalingMode.allCases) { scalingMode in
+ Button {
+ settingsStore.videoScalingMode = scalingMode
+ } label: {
+ if settingsStore.videoScalingMode == scalingMode {
+ Label(scalingMode.label, systemImage: "checkmark")
+ } else {
+ Text(scalingMode.label)
+ }
+ }
+ }
+ }
+ }
+
+ private var playbackSpeedMenu: some View {
+ Menu("Playback Speed") {
+ ForEach(PlexPlaybackRate.allCases) { playbackRate in
+ Button {
+ session.selectPlaybackRate(playbackRate)
+ } label: {
+ if session.engine.playbackRate == playbackRate {
+ Label(playbackRate.label, systemImage: "checkmark")
+ } else {
+ Text(playbackRate.label)
+ }
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var serverManagedMediaSelectionMenus: some View {
+ let selection = session.serverManagedMediaSelection
+
+ if !selection.audioOptions.isEmpty {
+ Menu("Audio Track") {
+ ForEach(selection.audioOptions) { option in
+ Button {
+ session.selectAudioStream(option.id)
+ } label: {
+ if option.isSelected {
+ Label(option.title, systemImage: "checkmark")
+ } else {
+ Text(option.title)
+ }
+ }
+ }
+ }
+ .disabled(!session.canChangeMediaSelection)
+ }
+
+ if !selection.subtitleOptions.isEmpty {
+ Menu("Subtitles") {
+ Button {
+ session.selectSubtitleStream(nil)
+ } label: {
+ if selection.subtitleOptions.contains(where: \.isSelected) {
+ Text("Off")
+ } else {
+ Label("Off", systemImage: "checkmark")
+ }
+ }
+
+ ForEach(selection.subtitleOptions) { option in
+ Button {
+ session.selectSubtitleStream(option.id)
+ } label: {
+ if option.isSelected {
+ Label(option.title, systemImage: "checkmark")
+ } else {
+ Text(option.title)
+ }
+ }
+ }
+ }
+ .disabled(!session.canChangeMediaSelection)
+ }
+ }
+}
+
+private struct PlexPlaybackRoutePicker: NSViewRepresentable {
+ let player: AVPlayer
+
+ func makeNSView(context: Context) -> AVRoutePickerView {
+ let routePickerView = AVRoutePickerView()
+ PlexPlaybackRoutePickerConfiguration.apply(
+ to: routePickerView,
+ player: player
+ )
+ return routePickerView
+ }
+
+ func updateNSView(_ routePickerView: AVRoutePickerView, context: Context) {
+ PlexPlaybackRoutePickerConfiguration.apply(
+ to: routePickerView,
+ player: player
+ )
+ }
+}
+
+private struct PlexAVPlayerView: NSViewRepresentable {
+ let player: AVPlayer
+ let playbackRate: PlexPlaybackRate
+ let audioOverlay: PlexAudioPlayerOverlay?
+ let mediaSelection: PlexPlaybackMediaSelection
+ let videoQualitySelection: PlexVideoQualitySelection
+ let videoDynamicRange: PlexVideoDisplayDynamicRange
+ let videoScalingMode: PlexVideoScalingMode
+ let canChangeMediaSelection: Bool
+ let nativeMediaSelectionGeneration: UInt
+ let markerAction: PlexPlaybackMarkerAction?
+ let presentationLifecycle: PlexPlayerPresentationLifecycle
+ let restorePlayerInterface: (@escaping (Bool) -> Void) -> Void
+ let onSelectPlaybackRate: (PlexPlaybackRate) -> Void
+ let onSelectAudioStream: (Int) -> Void
+ let onSelectSubtitleStream: (Int?) -> Void
+ let onSelectVideoQuality: (PlexVideoQuality) -> Void
+ let onSelectVideoDynamicRange: (PlexVideoDisplayDynamicRange) -> Void
+ let onSelectVideoScalingMode: (PlexVideoScalingMode) -> Void
+ let onUpdateNativeMediaSelectionAvailability: (
+ PlexNativeMediaSelectionAvailability,
+ UInt
+ ) -> Void
+ let onSkipMarker: () -> Void
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(
+ presentationLifecycle: presentationLifecycle,
+ nativeMediaSelectionGeneration: nativeMediaSelectionGeneration,
+ restorePlayerInterface: restorePlayerInterface,
+ onSelectPlaybackRate: onSelectPlaybackRate,
+ onSelectAudioStream: onSelectAudioStream,
+ onSelectSubtitleStream: onSelectSubtitleStream,
+ onSelectVideoQuality: onSelectVideoQuality,
+ onSelectVideoDynamicRange: onSelectVideoDynamicRange,
+ onSelectVideoScalingMode: onSelectVideoScalingMode,
+ onUpdateNativeMediaSelectionAvailability: onUpdateNativeMediaSelectionAvailability,
+ onSkipMarker: onSkipMarker
+ )
+ }
+
+ func makeNSView(context: Context) -> AVPlayerView {
+ let playerView = AVPlayerView()
+ playerView.player = player
+ context.coordinator.updatePlaybackSpeeds(
+ in: playerView,
+ playbackRate: playbackRate,
+ onSelectPlaybackRate: onSelectPlaybackRate
+ )
+ updatePresentationStyle(of: playerView)
+ PlexNativeVideoScalingConfiguration.apply(
+ to: playerView,
+ scalingMode: videoScalingMode
+ )
+ playerView.preferredDisplayDynamicRange = videoDynamicRange.avDisplayDynamicRange
+ playerView.updatesNowPlayingInfoCenter = false
+ playerView.delegate = context.coordinator
+ context.coordinator.installFullScreenKeyboardHandler(on: playerView)
+ playerView.pictureInPictureDelegate = context.coordinator
+ context.coordinator.updateAudioStage(in: playerView, overlay: audioOverlay)
+ context.coordinator.installMarkerOverlay(in: playerView)
+ context.coordinator.update(
+ mediaSelection: mediaSelection,
+ videoQualitySelection: videoQualitySelection,
+ videoDynamicRange: videoDynamicRange,
+ videoScalingMode: videoScalingMode,
+ canChangeMediaSelection: canChangeMediaSelection,
+ nativeMediaSelectionGeneration: nativeMediaSelectionGeneration,
+ markerAction: markerAction,
+ playerView: playerView,
+ restorePlayerInterface: restorePlayerInterface,
+ onSelectAudioStream: onSelectAudioStream,
+ onSelectSubtitleStream: onSelectSubtitleStream,
+ onSelectVideoQuality: onSelectVideoQuality,
+ onSelectVideoDynamicRange: onSelectVideoDynamicRange,
+ onSelectVideoScalingMode: onSelectVideoScalingMode,
+ onUpdateNativeMediaSelectionAvailability: onUpdateNativeMediaSelectionAvailability,
+ onSkipMarker: onSkipMarker
+ )
+ return playerView
+ }
+
+ func updateNSView(_ playerView: AVPlayerView, context: Context) {
+ if playerView.player !== player {
+ playerView.player = player
+ }
+ context.coordinator.updatePlaybackSpeeds(
+ in: playerView,
+ playbackRate: playbackRate,
+ onSelectPlaybackRate: onSelectPlaybackRate
+ )
+ updatePresentationStyle(of: playerView)
+ PlexNativeVideoScalingConfiguration.apply(
+ to: playerView,
+ scalingMode: videoScalingMode
+ )
+ let preferredDisplayDynamicRange = videoDynamicRange.avDisplayDynamicRange
+ if playerView.preferredDisplayDynamicRange != preferredDisplayDynamicRange {
+ playerView.preferredDisplayDynamicRange = preferredDisplayDynamicRange
+ }
+ context.coordinator.updateAudioStage(in: playerView, overlay: audioOverlay)
+ context.coordinator.update(
+ mediaSelection: mediaSelection,
+ videoQualitySelection: videoQualitySelection,
+ videoDynamicRange: videoDynamicRange,
+ videoScalingMode: videoScalingMode,
+ canChangeMediaSelection: canChangeMediaSelection,
+ nativeMediaSelectionGeneration: nativeMediaSelectionGeneration,
+ markerAction: markerAction,
+ playerView: playerView,
+ restorePlayerInterface: restorePlayerInterface,
+ onSelectAudioStream: onSelectAudioStream,
+ onSelectSubtitleStream: onSelectSubtitleStream,
+ onSelectVideoQuality: onSelectVideoQuality,
+ onSelectVideoDynamicRange: onSelectVideoDynamicRange,
+ onSelectVideoScalingMode: onSelectVideoScalingMode,
+ onUpdateNativeMediaSelectionAvailability: onUpdateNativeMediaSelectionAvailability,
+ onSkipMarker: onSkipMarker
+ )
+ }
+
+ static func dismantleNSView(_ playerView: AVPlayerView, coordinator: Coordinator) {
+ coordinator.stopFullScreenKeyboardHandler()
+ }
+
+ func sizeThatFits(
+ _ proposal: ProposedViewSize,
+ nsView: AVPlayerView,
+ context: Context
+ ) -> CGSize? {
+ PlexNativePlayerSizing.exactSize(for: proposal)
+ }
+
+ private func updatePresentationStyle(of playerView: AVPlayerView) {
+ let isAudio = audioOverlay != nil
+ let controlsStyle: AVPlayerViewControlsStyle = isAudio ? .inline : .floating
+ if playerView.controlsStyle != controlsStyle {
+ playerView.controlsStyle = controlsStyle
+ }
+ playerView.showsFullScreenToggleButton = !isAudio
+ playerView.allowsPictureInPicturePlayback = !isAudio
+ }
+
+ @MainActor
+ final class Coordinator: NSObject, @MainActor AVPlayerViewDelegate, @MainActor AVPlayerViewPictureInPictureDelegate {
+ private let presentationLifecycle: PlexPlayerPresentationLifecycle
+ private var fullScreenKeyboardHandler: PlexVideoFullScreenKeyboardHandler?
+ private var restorePlayerInterface: (@escaping (Bool) -> Void) -> Void
+ private var onSelectPlaybackRate: (PlexPlaybackRate) -> Void
+ private var mediaSelection: PlexPlaybackMediaSelection?
+ private var videoQualitySelection: PlexVideoQualitySelection?
+ private var videoDynamicRange: PlexVideoDisplayDynamicRange?
+ private var videoScalingMode: PlexVideoScalingMode?
+ private var canChangeMediaSelection = false
+ private var nativeMediaSelectionGeneration: UInt
+ private var markerAction: PlexPlaybackMarkerAction?
+ private var inspectedItemIdentifier: ObjectIdentifier?
+ private var nativeAvailability: PlexNativeMediaSelectionAvailability?
+ private var inspectionTask: Task?
+ private var playbackRateObservation: NSKeyValueObservation?
+ private weak var observedPlaybackRatePlayer: AVPlayer?
+ private var playbackRateObservationEpoch = PlexNativePlaybackRateObservationEpoch()
+ private let audioStageHost = PlexAudioPlayerStageHost()
+ private var markerHostingView: NSHostingView?
+ private var onSelectAudioStream: (Int) -> Void
+ private var onSelectSubtitleStream: (Int?) -> Void
+ private var onSelectVideoQuality: (PlexVideoQuality) -> Void
+ private var onSelectVideoDynamicRange: (PlexVideoDisplayDynamicRange) -> Void
+ private var onSelectVideoScalingMode: (PlexVideoScalingMode) -> Void
+ private var onUpdateNativeMediaSelectionAvailability: (
+ PlexNativeMediaSelectionAvailability,
+ UInt
+ ) -> Void
+ private var onSkipMarker: () -> Void
+
+ init(
+ presentationLifecycle: PlexPlayerPresentationLifecycle,
+ nativeMediaSelectionGeneration: UInt,
+ restorePlayerInterface: @escaping (@escaping (Bool) -> Void) -> Void,
+ onSelectPlaybackRate: @escaping (PlexPlaybackRate) -> Void,
+ onSelectAudioStream: @escaping (Int) -> Void,
+ onSelectSubtitleStream: @escaping (Int?) -> Void,
+ onSelectVideoQuality: @escaping (PlexVideoQuality) -> Void,
+ onSelectVideoDynamicRange: @escaping (PlexVideoDisplayDynamicRange) -> Void,
+ onSelectVideoScalingMode: @escaping (PlexVideoScalingMode) -> Void,
+ onUpdateNativeMediaSelectionAvailability: @escaping (
+ PlexNativeMediaSelectionAvailability,
+ UInt
+ ) -> Void,
+ onSkipMarker: @escaping () -> Void
+ ) {
+ self.presentationLifecycle = presentationLifecycle
+ self.nativeMediaSelectionGeneration = nativeMediaSelectionGeneration
+ self.restorePlayerInterface = restorePlayerInterface
+ self.onSelectPlaybackRate = onSelectPlaybackRate
+ self.onSelectAudioStream = onSelectAudioStream
+ self.onSelectSubtitleStream = onSelectSubtitleStream
+ self.onSelectVideoQuality = onSelectVideoQuality
+ self.onSelectVideoDynamicRange = onSelectVideoDynamicRange
+ self.onSelectVideoScalingMode = onSelectVideoScalingMode
+ self.onUpdateNativeMediaSelectionAvailability = onUpdateNativeMediaSelectionAvailability
+ self.onSkipMarker = onSkipMarker
+ }
+
+ func updatePlaybackSpeeds(
+ in playerView: AVPlayerView,
+ playbackRate: PlexPlaybackRate,
+ onSelectPlaybackRate: @escaping (PlexPlaybackRate) -> Void
+ ) {
+ self.onSelectPlaybackRate = onSelectPlaybackRate
+ PlexNativePlaybackSpeedConfiguration.apply(
+ to: playerView,
+ playbackRate: playbackRate
+ )
+
+ guard let player = playerView.player else {
+ playbackRateObservation?.invalidate()
+ playbackRateObservation = nil
+ observedPlaybackRatePlayer = nil
+ playbackRateObservationEpoch.invalidate()
+ return
+ }
+
+ guard !playbackRateObservationEpoch.isCurrent(player: player) else {
+ return
+ }
+
+ playbackRateObservation?.invalidate()
+ observedPlaybackRatePlayer = player
+ let ticket = playbackRateObservationEpoch.begin(player: player)
+ playbackRateObservation = player.observe(
+ \.defaultRate,
+ options: [.new]
+ ) { [weak self] _, change in
+ guard let rawValue = change.newValue else {
+ return
+ }
+ Task { @MainActor [weak self] in
+ guard let self,
+ let observedPlaybackRatePlayer,
+ let playbackRate = playbackRateObservationEpoch.playbackRate(
+ for: rawValue,
+ ticket: ticket,
+ player: observedPlaybackRatePlayer
+ ) else {
+ return
+ }
+ self.onSelectPlaybackRate(playbackRate)
+ }
+ }
+ }
+
+ func updateAudioStage(in playerView: AVPlayerView, overlay: PlexAudioPlayerOverlay?) {
+ audioStageHost.update(
+ in: playerView.contentOverlayView,
+ overlay: overlay
+ )
+ }
+
+ func installMarkerOverlay(in playerView: AVPlayerView) {
+ guard markerHostingView == nil, let overlayView = playerView.contentOverlayView else {
+ return
+ }
+
+ let hostingView = NSHostingView(
+ rootView: PlexSkipMarkerControl(action: nil, perform: onSkipMarker)
+ )
+ hostingView.translatesAutoresizingMaskIntoConstraints = false
+ overlayView.addSubview(hostingView)
+ NSLayoutConstraint.activate([
+ hostingView.trailingAnchor.constraint(
+ equalTo: overlayView.layoutMarginsGuide.trailingAnchor
+ ),
+ hostingView.bottomAnchor.constraint(equalTo: overlayView.bottomAnchor, constant: -84),
+ ])
+ markerHostingView = hostingView
+ }
+
+ func update(
+ mediaSelection: PlexPlaybackMediaSelection,
+ videoQualitySelection: PlexVideoQualitySelection,
+ videoDynamicRange: PlexVideoDisplayDynamicRange,
+ videoScalingMode: PlexVideoScalingMode,
+ canChangeMediaSelection: Bool,
+ nativeMediaSelectionGeneration: UInt,
+ markerAction: PlexPlaybackMarkerAction?,
+ playerView: AVPlayerView,
+ restorePlayerInterface: @escaping (@escaping (Bool) -> Void) -> Void,
+ onSelectAudioStream: @escaping (Int) -> Void,
+ onSelectSubtitleStream: @escaping (Int?) -> Void,
+ onSelectVideoQuality: @escaping (PlexVideoQuality) -> Void,
+ onSelectVideoDynamicRange: @escaping (PlexVideoDisplayDynamicRange) -> Void,
+ onSelectVideoScalingMode: @escaping (PlexVideoScalingMode) -> Void,
+ onUpdateNativeMediaSelectionAvailability: @escaping (
+ PlexNativeMediaSelectionAvailability,
+ UInt
+ ) -> Void,
+ onSkipMarker: @escaping () -> Void
+ ) {
+ self.restorePlayerInterface = restorePlayerInterface
+ self.onSelectAudioStream = onSelectAudioStream
+ self.onSelectSubtitleStream = onSelectSubtitleStream
+ self.onSelectVideoQuality = onSelectVideoQuality
+ self.onSelectVideoDynamicRange = onSelectVideoDynamicRange
+ self.onSelectVideoScalingMode = onSelectVideoScalingMode
+ self.onUpdateNativeMediaSelectionAvailability = onUpdateNativeMediaSelectionAvailability
+ self.onSkipMarker = onSkipMarker
+ let generationChanged = self.nativeMediaSelectionGeneration
+ != nativeMediaSelectionGeneration
+ self.nativeMediaSelectionGeneration = nativeMediaSelectionGeneration
+ if generationChanged {
+ nativeAvailability = nil
+ inspectionTask?.cancel()
+ inspectionTask = nil
+ playerView.actionPopUpButtonMenu = nil
+ }
+ let selectionChanged = self.mediaSelection != mediaSelection
+ let qualityChanged = self.videoQualitySelection != videoQualitySelection
+ let dynamicRangeChanged = self.videoDynamicRange != videoDynamicRange
+ let scalingModeChanged = self.videoScalingMode != videoScalingMode
+ let mediaAvailabilityChanged = self.canChangeMediaSelection != canChangeMediaSelection
+ let markerChanged = self.markerAction != markerAction
+ self.mediaSelection = mediaSelection
+ self.videoQualitySelection = videoQualitySelection
+ self.videoDynamicRange = videoDynamicRange
+ self.videoScalingMode = videoScalingMode
+ self.canChangeMediaSelection = canChangeMediaSelection
+ self.markerAction = markerAction
+ if markerChanged {
+ markerHostingView?.rootView = PlexSkipMarkerControl(
+ action: markerAction,
+ perform: onSkipMarker
+ )
+ }
+
+ guard let item = playerView.player?.currentItem else {
+ inspectedItemIdentifier = nil
+ nativeAvailability = nil
+ inspectionTask?.cancel()
+ inspectionTask = nil
+ playerView.actionPopUpButtonMenu = nil
+ return
+ }
+
+ let itemIdentifier = ObjectIdentifier(item)
+ if generationChanged, inspectedItemIdentifier == itemIdentifier {
+ return
+ }
+ if inspectedItemIdentifier != itemIdentifier {
+ inspectedItemIdentifier = itemIdentifier
+ nativeAvailability = nil
+ inspectionTask?.cancel()
+ playerView.actionPopUpButtonMenu = nil
+ inspectNativeOptions(for: item, playerView: playerView)
+ return
+ }
+
+ if selectionChanged || qualityChanged || dynamicRangeChanged || scalingModeChanged
+ || mediaAvailabilityChanged || markerChanged,
+ let nativeAvailability {
+ playerView.actionPopUpButtonMenu = makeActionMenu(
+ for: mediaSelection,
+ videoQualitySelection: videoQualitySelection,
+ videoDynamicRange: videoDynamicRange,
+ videoScalingMode: videoScalingMode,
+ nativeAvailability: nativeAvailability,
+ markerAction: markerAction
+ )
+ }
+ }
+
+ func installFullScreenKeyboardHandler(on playerView: AVPlayerView) {
+ fullScreenKeyboardHandler = PlexVideoFullScreenKeyboardHandler(
+ playerView: playerView,
+ lifecycle: presentationLifecycle
+ )
+ fullScreenKeyboardHandler?.start()
+ }
+
+ func stopFullScreenKeyboardHandler() {
+ fullScreenKeyboardHandler?.stop()
+ fullScreenKeyboardHandler = nil
+ }
+
+ func playerViewWillEnterFullScreen(_ playerView: AVPlayerView) {
+ presentationLifecycle.willEnterFullScreen()
+ }
+
+ func playerViewDidEnterFullScreen(_ playerView: AVPlayerView) {
+ presentationLifecycle.didEnterFullScreen()
+ }
+
+ func playerViewWillExitFullScreen(_ playerView: AVPlayerView) {
+ presentationLifecycle.willExitFullScreen()
+ }
+
+ func playerViewDidExitFullScreen(_ playerView: AVPlayerView) {
+ presentationLifecycle.didExitFullScreen()
+ }
+
+ func playerView(
+ _ playerView: AVPlayerView,
+ restoreUserInterfaceForFullScreenExitWithCompletionHandler completionHandler: @escaping (Bool) -> Void
+ ) {
+ restorePlayerInterface(completionHandler)
+ }
+
+ func playerViewWillStartPicture(inPicture playerView: AVPlayerView) {
+ presentationLifecycle.willStartPictureInPicture()
+ }
+
+ func playerView(
+ _ playerView: AVPlayerView,
+ failedToStartPictureInPictureWithError error: any Error
+ ) {
+ presentationLifecycle.failedToStartPictureInPicture()
+ }
+
+ func playerViewDidStopPicture(inPicture playerView: AVPlayerView) {
+ presentationLifecycle.didStopPictureInPicture()
+ }
+
+ func playerView(
+ _ playerView: AVPlayerView,
+ restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void
+ ) {
+ restorePlayerInterface(completionHandler)
+ }
+
+ private func inspectNativeOptions(for item: AVPlayerItem, playerView: AVPlayerView) {
+ let inspectionGeneration = nativeMediaSelectionGeneration
+ inspectionTask = Task { @MainActor [weak self, weak item, weak playerView] in
+ guard let self, let item, let playerView else {
+ return
+ }
+
+ guard let availability = try? await PlexNativeMediaInspector
+ .mediaSelectionAvailability(asset: item.asset) else {
+ return
+ }
+
+ guard !Task.isCancelled,
+ inspectedItemIdentifier == ObjectIdentifier(item),
+ nativeMediaSelectionGeneration == inspectionGeneration,
+ let mediaSelection,
+ let videoQualitySelection,
+ let videoDynamicRange,
+ let videoScalingMode else {
+ return
+ }
+ nativeAvailability = availability
+ onUpdateNativeMediaSelectionAvailability(availability, inspectionGeneration)
+ playerView.actionPopUpButtonMenu = makeActionMenu(
+ for: mediaSelection,
+ videoQualitySelection: videoQualitySelection,
+ videoDynamicRange: videoDynamicRange,
+ videoScalingMode: videoScalingMode,
+ nativeAvailability: availability,
+ markerAction: markerAction
+ )
+ }
+ }
+
+ private func makeActionMenu(
+ for selection: PlexPlaybackMediaSelection,
+ videoQualitySelection: PlexVideoQualitySelection,
+ videoDynamicRange: PlexVideoDisplayDynamicRange,
+ videoScalingMode: PlexVideoScalingMode,
+ nativeAvailability: PlexNativeMediaSelectionAvailability,
+ markerAction: PlexPlaybackMarkerAction?
+ ) -> NSMenu? {
+ let includesVideoQuality = videoQualitySelection.isVideo
+ let serverManagedSelection = PlexServerManagedMediaSelection(
+ selection: selection,
+ nativeAvailability: nativeAvailability
+ )
+ let includesAudio = !serverManagedSelection.audioOptions.isEmpty
+ let includesSubtitles = !serverManagedSelection.subtitleOptions.isEmpty
+ guard markerAction != nil || includesVideoQuality || includesAudio || includesSubtitles else {
+ return nil
+ }
+
+ let menu = NSMenu(title: "Playback Options")
+ if let markerAction {
+ let item = NSMenuItem(
+ title: markerAction.label,
+ action: #selector(skipMarker),
+ keyEquivalent: ""
+ )
+ item.target = self
+ item.image = NSImage(
+ systemSymbolName: "forward.end.fill",
+ accessibilityDescription: nil
+ )
+ menu.addItem(item)
+ if includesVideoQuality || includesAudio || includesSubtitles {
+ menu.addItem(.separator())
+ }
+ }
+ if includesVideoQuality {
+ let item = NSMenuItem(title: "Video Quality", action: nil, keyEquivalent: "")
+ item.submenu = videoQualityMenu(selection: videoQualitySelection)
+ menu.addItem(item)
+ }
+ if videoQualitySelection.isVideo {
+ let item = NSMenuItem(title: "Video Dynamic Range", action: nil, keyEquivalent: "")
+ item.submenu = videoDynamicRangeMenu(selection: videoDynamicRange)
+ menu.addItem(item)
+
+ let scalingItem = NSMenuItem(
+ title: "Video Scaling",
+ action: nil,
+ keyEquivalent: ""
+ )
+ scalingItem.submenu = videoScalingMenu(selection: videoScalingMode)
+ menu.addItem(scalingItem)
+ }
+ if includesAudio {
+ let item = NSMenuItem(title: "Audio", action: nil, keyEquivalent: "")
+ item.submenu = audioMenu(options: serverManagedSelection.audioOptions)
+ menu.addItem(item)
+ }
+ if includesSubtitles {
+ let item = NSMenuItem(title: "Subtitles", action: nil, keyEquivalent: "")
+ item.submenu = subtitleMenu(options: serverManagedSelection.subtitleOptions)
+ menu.addItem(item)
+ }
+ return menu
+ }
+
+ private func audioMenu(options: [PlexMediaSelectionOption]) -> NSMenu {
+ let menu = NSMenu(title: "Audio")
+ for option in options {
+ let item = NSMenuItem(
+ title: option.title,
+ action: #selector(selectAudioStream(_:)),
+ keyEquivalent: ""
+ )
+ item.target = self
+ item.representedObject = option.id
+ item.state = option.isSelected ? .on : .off
+ item.isEnabled = canChangeMediaSelection
+ menu.addItem(item)
+ }
+ return menu
+ }
+
+ private func subtitleMenu(options: [PlexMediaSelectionOption]) -> NSMenu {
+ let menu = NSMenu(title: "Subtitles")
+ let offItem = NSMenuItem(
+ title: "Off",
+ action: #selector(selectSubtitleStream(_:)),
+ keyEquivalent: ""
+ )
+ offItem.target = self
+ offItem.representedObject = 0
+ offItem.state = options.contains(where: \.isSelected) ? .off : .on
+ offItem.isEnabled = canChangeMediaSelection
+ menu.addItem(offItem)
+ menu.addItem(.separator())
+
+ for option in options {
+ let item = NSMenuItem(
+ title: option.title,
+ action: #selector(selectSubtitleStream(_:)),
+ keyEquivalent: ""
+ )
+ item.target = self
+ item.representedObject = option.id
+ item.state = option.isSelected ? .on : .off
+ item.isEnabled = canChangeMediaSelection
+ menu.addItem(item)
+ }
+ return menu
+ }
+
+ private func videoQualityMenu(selection: PlexVideoQualitySelection) -> NSMenu {
+ let menu = NSMenu(title: "Video Quality")
+ for quality in PlexVideoQuality.allCases {
+ let item = NSMenuItem(
+ title: quality.label,
+ action: #selector(selectVideoQuality(_:)),
+ keyEquivalent: ""
+ )
+ item.target = self
+ item.representedObject = quality.rawValue
+ item.state = quality == selection.selectedQuality ? .on : .off
+ item.isEnabled = selection.canSelect(quality)
+ menu.addItem(item)
+ }
+ return menu
+ }
+
+ private func videoDynamicRangeMenu(selection: PlexVideoDisplayDynamicRange) -> NSMenu {
+ let menu = NSMenu(title: "Video Dynamic Range")
+ for dynamicRange in PlexVideoDisplayDynamicRange.allCases {
+ let item = NSMenuItem(
+ title: dynamicRange.label,
+ action: #selector(selectVideoDynamicRange(_:)),
+ keyEquivalent: ""
+ )
+ item.target = self
+ item.representedObject = dynamicRange.rawValue
+ item.state = dynamicRange == selection ? .on : .off
+ menu.addItem(item)
+ }
+ return menu
+ }
+
+ private func videoScalingMenu(selection: PlexVideoScalingMode) -> NSMenu {
+ let menu = NSMenu(title: "Video Scaling")
+ for scalingMode in PlexVideoScalingMode.allCases {
+ let item = NSMenuItem(
+ title: scalingMode.label,
+ action: #selector(selectVideoScalingMode(_:)),
+ keyEquivalent: ""
+ )
+ item.target = self
+ item.representedObject = scalingMode.rawValue
+ item.state = scalingMode == selection ? .on : .off
+ menu.addItem(item)
+ }
+ return menu
+ }
+
+ @objc private func selectAudioStream(_ sender: NSMenuItem) {
+ guard let streamID = sender.representedObject as? Int else {
+ return
+ }
+ onSelectAudioStream(streamID)
+ }
+
+ @objc private func selectSubtitleStream(_ sender: NSMenuItem) {
+ guard let streamID = sender.representedObject as? Int else {
+ return
+ }
+ onSelectSubtitleStream(streamID == 0 ? nil : streamID)
+ }
+
+ @objc private func selectVideoQuality(_ sender: NSMenuItem) {
+ guard let rawValue = sender.representedObject as? String,
+ let quality = PlexVideoQuality(rawValue: rawValue) else {
+ return
+ }
+ onSelectVideoQuality(quality)
+ }
+
+ @objc private func selectVideoDynamicRange(_ sender: NSMenuItem) {
+ guard let rawValue = sender.representedObject as? String,
+ let dynamicRange = PlexVideoDisplayDynamicRange(rawValue: rawValue) else {
+ return
+ }
+ onSelectVideoDynamicRange(dynamicRange)
+ }
+
+ @objc private func selectVideoScalingMode(_ sender: NSMenuItem) {
+ guard let rawValue = sender.representedObject as? String,
+ let scalingMode = PlexVideoScalingMode(rawValue: rawValue) else {
+ return
+ }
+ onSelectVideoScalingMode(scalingMode)
+ }
+
+ @objc private func skipMarker() {
+ onSkipMarker()
+ }
+
+ }
+}
+
+@MainActor
+struct PlexNativePlaybackRateObservationEpoch {
+ struct Ticket: Equatable, Sendable {
+ let generation: UInt
+ let playerIdentifier: ObjectIdentifier
+ }
+
+ private var generation: UInt = 0
+ private var currentTicket: Ticket?
+
+ mutating func begin(player: AVPlayer) -> Ticket {
+ generation &+= 1
+ let ticket = Ticket(
+ generation: generation,
+ playerIdentifier: ObjectIdentifier(player)
+ )
+ currentTicket = ticket
+ return ticket
+ }
+
+ mutating func invalidate() {
+ generation &+= 1
+ currentTicket = nil
+ }
+
+ func isCurrent(player: AVPlayer) -> Bool {
+ guard let currentTicket else {
+ return false
+ }
+ return currentTicket.generation == generation
+ && currentTicket.playerIdentifier == ObjectIdentifier(player)
+ }
+
+ func playbackRate(
+ for observedRawValue: Float,
+ ticket: Ticket,
+ player: AVPlayer
+ ) -> PlexPlaybackRate? {
+ guard currentTicket == ticket,
+ ticket.generation == generation,
+ ticket.playerIdentifier == ObjectIdentifier(player),
+ abs(player.defaultRate - observedRawValue) < 0.001 else {
+ return nil
+ }
+ return PlexPlaybackRate(remoteCommandValue: observedRawValue)
+ }
+}
+
+extension PlexVideoDisplayDynamicRange {
+ var avDisplayDynamicRange: AVDisplayDynamicRange {
+ switch self {
+ case .automatic: .automatic
+ case .standard: .standard
+ case .constrainedHigh: .constrainedHigh
+ case .high: .high
+ }
+ }
+}
+
+private struct PlexSkipMarkerControl: View {
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+ let action: PlexPlaybackMarkerAction?
+ let perform: () -> Void
+
+ var body: some View {
+ Group {
+ if let action {
+ Button(action: perform) {
+ Label(action.label, systemImage: "forward.end.fill")
+ }
+ .buttonStyle(.glassProminent)
+ .controlSize(.large)
+ .accessibilityHint(action.accessibilityHint)
+ .transition(.move(edge: .trailing).combined(with: .opacity))
+ }
+ }
+ .animation(reduceMotion ? nil : .smooth, value: action?.id)
+ }
+}
+
+@MainActor
+@Observable
+final class PlexPlayerSessionModel {
+ private(set) var presentation: PlexPlaybackPresentation
+ let engine = PlexPlaybackEngine()
+ var isLoading = false
+ var errorMessage: String?
+ private(set) var qualitySuggestion: PlexPlaybackQualitySuggestion?
+ private(set) var canRetryPlayback = false
+ private(set) var isRefreshingQueue = false
+ private(set) var isChangingShuffle = false
+ private(set) var isAddingToQueue = false
+ private(set) var isEditingQueue = false
+ private(set) var queueErrorMessage: String?
+ private(set) var postPlayHubs: [PlexHub] = []
+ private(set) var isLoadingPostPlay = false
+ private(set) var postPlayErrorMessage: String?
+ private(set) var postPlayNextItem: PlexMediaItem?
+ private(set) var postPlayCountdownTotalSeconds: Int?
+ private(set) var postPlayCountdownRemainingSeconds: Int?
+
+ @ObservationIgnored private let browserStore: PlexBrowserStore
+ @ObservationIgnored private let settingsStore: PlexSettingsStore
+ @ObservationIgnored private let userInteractionStore: PlexUserInteractionStore
+ @ObservationIgnored private let coordinator: PlexPlayerCoordinator
+ @ObservationIgnored private let downloadsStore: PlexDownloadsStore?
+ @ObservationIgnored private let bandwidthRegistry: PlexPlaybackBandwidthRegistry
+ @ObservationIgnored private let timelineReporter: PlexTimelineReportSequencer
+ @ObservationIgnored private let nowPlayingController = PlexNowPlayingController()
+ @ObservationIgnored private let nowPlayingArtworkLoader = PlexNowPlayingArtworkLoader()
+ private var queue: PlexPlaybackQueue?
+ @ObservationIgnored private var timelineTask: Task?
+ @ObservationIgnored private var seekTask: Task?
+ @ObservationIgnored private var nowPlayingArtworkTask: Task?
+ @ObservationIgnored private var postPlayCountdownTask: Task?
+ @ObservationIgnored private var timelineCadence = PlexTimelineReportCadence()
+ @ObservationIgnored private var handledEndSessionIdentifier: String?
+ @ObservationIgnored private var isTransitioning = false
+ @ObservationIgnored private var didStop = false
+ @ObservationIgnored private var sessionEpoch = PlexPlaybackSessionEpoch()
+ @ObservationIgnored private var repeatMode: PlexPlaybackRepeatMode = .off
+ @ObservationIgnored private var nativeMediaSelectionState = PlexNativeMediaSelectionState()
+ @ObservationIgnored private var automaticMarkerTransition = PlexAutomaticPlaybackMarkerTransition()
+ @ObservationIgnored private var pendingRewindOnResumeRequest: PlexPlaybackSeekSequence.Request?
+ @ObservationIgnored private var qualitySuggestionState =
+ PlexPlaybackQualitySuggestionSessionState()
+
+ init(
+ presentation: PlexPlaybackPresentation,
+ browserStore: PlexBrowserStore,
+ settingsStore: PlexSettingsStore,
+ userInteractionStore: PlexUserInteractionStore,
+ coordinator: PlexPlayerCoordinator,
+ downloadsStore: PlexDownloadsStore? = nil,
+ bandwidthRegistry: PlexPlaybackBandwidthRegistry = PlexPlaybackBandwidthRegistry()
+ ) {
+ self.presentation = presentation
+ self.browserStore = browserStore
+ self.settingsStore = settingsStore
+ self.userInteractionStore = userInteractionStore
+ self.coordinator = coordinator
+ self.downloadsStore = downloadsStore
+ self.bandwidthRegistry = bandwidthRegistry
+ let offlinePackageID = presentation.offlinePackageID
+ timelineReporter = PlexTimelineReportSequencer { [browserStore, downloadsStore] update in
+ if let offlinePackageID, let downloadsStore {
+ return await downloadsStore.recordOfflineTimeline(
+ packageID: offlinePackageID,
+ update: update
+ )
+ }
+ return await browserStore.reportTimeline(update)
+ }
+ queue = presentation.queue
+ }
+
+ var playbackMethodLabel: String {
+ isOfflinePlayback ? "Offline" : presentation.plan.method.label
+ }
+
+ var playbackStatusLabel: String {
+ engine.status.label
+ }
+
+ var playbackWaitingReasonLabel: String? {
+ engine.waitingReason?.diagnosticLabel
+ }
+
+ var playbackMetricDiagnosticFacts: [PlexPlaybackMetricDiagnosticFact] {
+ engine.metricFacts?.diagnosticFacts ?? []
+ }
+
+ var showsVideoPreparationStage: Bool {
+ PlexVideoPreparationPolicy.shouldPresent(
+ mediaKind: presentation.plan.mediaKind,
+ status: engine.status,
+ isLoading: isLoading
+ )
+ }
+
+ var mediaSelection: PlexPlaybackMediaSelection {
+ PlexPlaybackMediaSelection(
+ item: presentation.item,
+ source: presentation.plan.source
+ )
+ }
+
+ var serverManagedMediaSelection: PlexServerManagedMediaSelection {
+ PlexServerManagedMediaSelection(
+ selection: mediaSelection,
+ nativeAvailability: nativeMediaSelectionState.availability
+ )
+ }
+
+ var videoQualitySelection: PlexVideoQualitySelection {
+ let isVideo = presentation.item.media.indices.contains(presentation.plan.source.mediaIndex)
+ && presentation.item.media[presentation.plan.source.mediaIndex]
+ .videoCodec?.nilIfBlank != nil
+ return PlexVideoQualitySelection(
+ selectedQuality: presentation.videoQuality,
+ isVideo: isVideo,
+ canChange: isVideo
+ && !isOfflinePlayback
+ && !isQueueBusy
+ && pendingRewindOnResumeRequest == nil
+ && PlexPlaybackReconfigurationPolicy(status: engine.status).canReload
+ )
+ }
+
+ var canChangeMediaSelection: Bool {
+ !isOfflinePlayback
+ && !isQueueBusy
+ && pendingRewindOnResumeRequest == nil
+ && mediaSelection.partID != nil
+ && PlexPlaybackReconfigurationPolicy(status: engine.status).canReload
+ }
+
+ var nativeMediaSelectionGeneration: UInt {
+ nativeMediaSelectionState.generation
+ }
+
+ var queuePresentation: PlexPlaybackQueuePresentation? {
+ queue?.presentation
+ }
+
+ var isUpdatingQueue: Bool {
+ isRefreshingQueue || isChangingShuffle || isAddingToQueue || isEditingQueue
+ }
+
+ var hasPostPlayPresentation: Bool {
+ postPlayNextItem != nil
+ || isLoadingPostPlay
+ || postPlayErrorMessage != nil
+ || !postPlayHubs.isEmpty
+ }
+
+ var isPostPlayCountdownActive: Bool {
+ postPlayCountdownRemainingSeconds != nil
+ }
+
+ var supportsCurrentItemWatchedStateMutation: Bool {
+ !isOfflinePlayback && browserStore.supportsWatchedStateMutation(for: presentation.item)
+ }
+
+ var supportsCurrentItemPersonalRating: Bool {
+ !isOfflinePlayback && browserStore.supportsPersonalRatings
+ }
+
+ var isUpdatingCurrentItemWatchedState: Bool {
+ browserStore.isUpdatingWatchedState(for: presentation.item)
+ }
+
+ var isUpdatingCurrentItemPersonalRating: Bool {
+ browserStore.isUpdatingPersonalRating(for: presentation.item)
+ }
+
+ var canShowCurrentItemInLibrary: Bool {
+ !isOfflinePlayback && PlexPlayerLibraryHandoffPolicy.canOpen(
+ playbackServerIdentifier: presentation.serverIdentifier,
+ browserServerIdentifier: activeBrowserServerIdentifier
+ )
+ }
+
+ private var isOfflinePlayback: Bool {
+ presentation.offlinePackageID != nil
+ }
+
+ func setCurrentItemWatched(_ watched: Bool) async throws {
+ let ticket = PlexPlayerItemMutationTicket(presentation: presentation)
+ let refreshedItem = try await browserStore.setWatched(watched, for: presentation.item)
+ guard !didStop, ticket.accepts(presentation) else {
+ return
+ }
+ replaceCurrentItem(with: refreshedItem)
+ }
+
+ func setCurrentItemPersonalRating(_ rating: Double?) async throws {
+ let ticket = PlexPlayerItemMutationTicket(presentation: presentation)
+ let refreshedItem = try await browserStore.setPersonalRating(
+ rating,
+ for: presentation.item
+ )
+ guard !didStop, ticket.accepts(presentation) else {
+ return
+ }
+ replaceCurrentItem(with: refreshedItem)
+ }
+
+ func canAddToQueue(_ item: PlexMediaItem) -> Bool {
+ guard !isQueueBusy,
+ pendingRewindOnResumeRequest == nil,
+ presentation.serverIdentifier?.nilIfBlank == activeBrowserServerIdentifier,
+ let queue else {
+ return false
+ }
+ return queue.canAdd(item)
+ }
+
+ func addToQueue(
+ _ item: PlexMediaItem,
+ insertion: PlexPlayQueueInsertion
+ ) async throws {
+ guard let ticket = currentSessionTicket, canAddToQueue(item),
+ var updatedQueue = queue else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+
+ isAddingToQueue = true
+ updatePlaybackControlAvailability()
+ defer {
+ if isCurrentSession(ticket) {
+ isAddingToQueue = false
+ updatePlaybackControlAvailability()
+ }
+ }
+
+ let page = try await browserStore.addToPlayQueue(
+ item,
+ queueID: updatedQueue.id,
+ insertion: insertion
+ )
+ try checkCurrentSession(ticket)
+ try updatedQueue.applyAddition(page)
+ queue = updatedQueue
+ queueErrorMessage = nil
+ updateNowPlaying()
+ }
+
+ func canRemoveUpcomingItem(playQueueItemID: String) -> Bool {
+ !isQueueBusy
+ && pendingRewindOnResumeRequest == nil
+ && queue?.canRemoveUpcomingItem(playQueueItemID: playQueueItemID) == true
+ }
+
+ func canMoveUpcomingItem(
+ playQueueItemID: String,
+ direction: PlexPlayQueueItemMoveDirection
+ ) -> Bool {
+ !isQueueBusy
+ && pendingRewindOnResumeRequest == nil
+ && queue?.moveRequest(
+ for: playQueueItemID,
+ direction: direction
+ ) != nil
+ }
+
+ var canReorderUpcomingItems: Bool {
+ !isQueueBusy
+ && pendingRewindOnResumeRequest == nil
+ && queue?.canReorderLoadedUpcomingItems == true
+ }
+
+ func removeUpcomingItem(playQueueItemID: String) {
+ guard canRemoveUpcomingItem(playQueueItemID: playQueueItemID),
+ let ticket = currentSessionTicket else {
+ return
+ }
+ userInteractionStore.recordInteraction()
+ Task {
+ await removeUpcomingItem(
+ playQueueItemID: playQueueItemID,
+ ticket: ticket
+ )
+ }
+ }
+
+ func moveUpcomingItem(
+ playQueueItemID: String,
+ direction: PlexPlayQueueItemMoveDirection
+ ) {
+ guard canMoveUpcomingItem(
+ playQueueItemID: playQueueItemID,
+ direction: direction
+ ), let move = queue?.moveRequest(
+ for: playQueueItemID,
+ direction: direction
+ ), let ticket = currentSessionTicket else {
+ return
+ }
+ userInteractionStore.recordInteraction()
+ Task {
+ await moveUpcomingItem(
+ move: move,
+ ticket: ticket
+ )
+ }
+ }
+
+ @discardableResult
+ func moveUpcomingItems(
+ fromOffsets sourceOffsets: IndexSet,
+ toOffset destinationOffset: Int
+ ) -> Bool {
+ guard canReorderUpcomingItems,
+ let move = queue?.moveRequest(
+ fromUpcomingOffsets: sourceOffsets,
+ toUpcomingOffset: destinationOffset
+ ), let ticket = currentSessionTicket else {
+ return false
+ }
+ userInteractionStore.recordInteraction()
+ Task {
+ await moveUpcomingItem(move: move, ticket: ticket)
+ }
+ return true
+ }
+
+ var artworkServerURL: URL? {
+ browserStore.connectionStore.resolvedServerURL
+ }
+
+ var artworkToken: String {
+ browserStore.connectionStore.settings.trimmedServerToken
+ }
+
+ private func replaceCurrentItem(with item: PlexMediaItem) {
+ replacePresentation(PlexPlaybackPresentation(
+ item: item,
+ plan: presentation.plan,
+ queue: queue,
+ videoQuality: presentation.videoQuality,
+ serverIdentifier: presentation.serverIdentifier,
+ queueSourcePreference: presentation.queueSourcePreference
+ ))
+ }
+
+ private func replacePresentation(_ presentation: PlexPlaybackPresentation) {
+ let itemChanged = self.presentation.item.ratingKey != presentation.item.ratingKey
+ if itemChanged
+ || self.presentation.plan.sessionIdentifier != presentation.plan.sessionIdentifier {
+ cancelPendingRewindOnResume()
+ automaticMarkerTransition.reset()
+ }
+ if itemChanged {
+ qualitySuggestion = nil
+ qualitySuggestionState.moveToItem(itemKey: presentation.item.ratingKey)
+ }
+ if self.presentation.plan.sessionIdentifier != presentation.plan.sessionIdentifier {
+ clearPostPlay()
+ }
+ self.presentation = presentation
+ coordinator.updateCurrentPlayback(for: self, presentation: presentation)
+ }
+
+ private func clearPostPlay() {
+ postPlayCountdownTask?.cancel()
+ postPlayCountdownTask = nil
+ postPlayHubs = []
+ isLoadingPostPlay = false
+ postPlayErrorMessage = nil
+ postPlayNextItem = nil
+ postPlayCountdownTotalSeconds = nil
+ postPlayCountdownRemainingSeconds = nil
+ }
+
+ var artworkClientContext: PlexClientContext {
+ PlexClientContext(
+ clientIdentifier: browserStore.connectionStore.settings.clientIdentifier
+ )
+ }
+
+ func requestQueueRefresh() {
+ Task {
+ await refreshQueueWindow()
+ }
+ }
+
+ func playUpcomingItem(playQueueItemID: String) {
+ guard pendingRewindOnResumeRequest == nil,
+ let ticket = currentSessionTicket else {
+ return
+ }
+ userInteractionStore.recordInteraction()
+ clearPostPlayCountdown()
+ Task {
+ await navigate(toPlayQueueItemID: playQueueItemID, ticket: ticket)
+ }
+ }
+
+ func requestPostPlayRefresh() {
+ guard canShowCurrentItemInLibrary, let ticket = currentSessionTicket else {
+ return
+ }
+ Task {
+ await loadPostPlay(ticket: ticket)
+ }
+ }
+
+ func playPostPlayItem(_ item: PlexMediaItem) {
+ userInteractionStore.recordInteraction()
+ if postPlayNextItem?.playQueueItemID?.nilIfBlank == item.playQueueItemID?.nilIfBlank,
+ postPlayNextItem?.ratingKey == item.ratingKey {
+ playPostPlayNextItem()
+ return
+ }
+ guard let ticket = currentSessionTicket,
+ canShowCurrentItemInLibrary,
+ engine.status == .ended,
+ postPlayHubs.lazy.flatMap(\.metadata).contains(where: {
+ $0.ratingKey == item.ratingKey
+ }) else {
+ return
+ }
+ Task {
+ await startPostPlayItem(item, ticket: ticket)
+ }
+ }
+
+ func playPostPlayNextItem() {
+ guard postPlayNextItem != nil,
+ engine.status == .ended,
+ let ticket = currentSessionTicket else {
+ return
+ }
+ userInteractionStore.recordInteraction()
+ clearPostPlayCountdown()
+ Task {
+ await navigate(
+ .next,
+ completedCurrentItem: false,
+ ticket: ticket
+ )
+ }
+ }
+
+ func cancelPostPlayAutoplay() {
+ userInteractionStore.recordInteraction()
+ clearPostPlayCountdown()
+ }
+
+ private func clearPostPlayCountdown() {
+ postPlayCountdownTask?.cancel()
+ postPlayCountdownTask = nil
+ postPlayCountdownTotalSeconds = nil
+ postPlayCountdownRemainingSeconds = nil
+ }
+
+ func setShuffled(_ isShuffled: Bool) {
+ guard pendingRewindOnResumeRequest == nil,
+ let ticket = currentSessionTicket else {
+ return
+ }
+ userInteractionStore.recordInteraction()
+ Task {
+ await changeShuffle(to: isShuffled, ticket: ticket)
+ }
+ }
+
+ func setRepeatMode(_ repeatMode: PlexPlaybackRepeatMode) {
+ guard repeatMode != self.repeatMode,
+ repeatMode != .all || queue?.canRepeatAll == true else {
+ return
+ }
+ userInteractionStore.recordInteraction()
+ self.repeatMode = repeatMode
+ coordinator.updateRepeatMode(
+ for: self,
+ repeatMode: repeatMode,
+ canRepeatAll: queue?.canRepeatAll == true
+ )
+ nowPlayingController.updateRepeatMode(repeatMode)
+ }
+
+ func refreshQueueWindow() async {
+ guard let ticket = currentSessionTicket, !isQueueBusy,
+ var updatedQueue = queue,
+ let currentQueueItemID = updatedQueue.currentItem.playQueueItemID else {
+ return
+ }
+
+ isRefreshingQueue = true
+ updatePlaybackControlAvailability()
+ defer {
+ if isCurrentSession(ticket) {
+ isRefreshingQueue = false
+ updatePlaybackControlAvailability()
+ }
+ }
+
+ do {
+ let page = try await browserStore.refreshPlayQueueWindow(
+ queueID: updatedQueue.id,
+ centeredOn: currentQueueItemID
+ )
+ try checkCurrentSession(ticket)
+ try updatedQueue.replaceWindow(with: page, centeredOn: currentQueueItemID)
+ queue = updatedQueue
+ queueErrorMessage = nil
+ updateNowPlaying()
+ } catch is CancellationError {
+ return
+ } catch {
+ if isCurrentSession(ticket) {
+ queueErrorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ private func changeShuffle(
+ to isShuffled: Bool,
+ ticket: PlexPlaybackSessionEpoch.Ticket
+ ) async {
+ guard isCurrentSession(ticket), !isQueueBusy,
+ var updatedQueue = queue,
+ updatedQueue.canChangeShuffle,
+ updatedQueue.isShuffled != isShuffled else {
+ return
+ }
+
+ isChangingShuffle = true
+ updatePlaybackControlAvailability()
+ defer {
+ if isCurrentSession(ticket) {
+ isChangingShuffle = false
+ updatePlaybackControlAvailability()
+ }
+ }
+
+ do {
+ let page = try await browserStore.setPlayQueueShuffled(
+ isShuffled,
+ queueID: updatedQueue.id
+ )
+ try checkCurrentSession(ticket)
+ try updatedQueue.applyShuffleMutation(
+ page,
+ expectedShuffled: isShuffled
+ )
+ queue = updatedQueue
+ queueErrorMessage = nil
+ updateNowPlaying()
+ } catch is CancellationError {
+ return
+ } catch {
+ if isCurrentSession(ticket) {
+ queueErrorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ private func removeUpcomingItem(
+ playQueueItemID: String,
+ ticket: PlexPlaybackSessionEpoch.Ticket
+ ) async {
+ guard isCurrentSession(ticket), !isQueueBusy,
+ var updatedQueue = queue,
+ updatedQueue.canRemoveUpcomingItem(
+ playQueueItemID: playQueueItemID
+ ) else {
+ return
+ }
+
+ isEditingQueue = true
+ updatePlaybackControlAvailability()
+ defer {
+ if isCurrentSession(ticket) {
+ isEditingQueue = false
+ updatePlaybackControlAvailability()
+ }
+ }
+
+ do {
+ let page = try await browserStore.removePlayQueueItem(
+ queueID: updatedQueue.id,
+ playQueueItemID: playQueueItemID
+ )
+ try checkCurrentSession(ticket)
+ try updatedQueue.applyRemoval(
+ page,
+ removedPlayQueueItemID: playQueueItemID
+ )
+ queue = updatedQueue
+ queueErrorMessage = nil
+ updateNowPlaying()
+ } catch is CancellationError {
+ return
+ } catch {
+ if isCurrentSession(ticket) {
+ queueErrorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ private func moveUpcomingItem(
+ move: PlexPlayQueueItemMove,
+ ticket: PlexPlaybackSessionEpoch.Ticket
+ ) async {
+ guard isCurrentSession(ticket), !isQueueBusy,
+ var updatedQueue = queue,
+ updatedQueue.canApplyMoveRequest(move) else {
+ return
+ }
+
+ isEditingQueue = true
+ updatePlaybackControlAvailability()
+ defer {
+ if isCurrentSession(ticket) {
+ isEditingQueue = false
+ updatePlaybackControlAvailability()
+ }
+ }
+
+ do {
+ let page = try await browserStore.movePlayQueueItem(
+ queueID: updatedQueue.id,
+ move: move
+ )
+ try checkCurrentSession(ticket)
+ try updatedQueue.applyMove(page, request: move)
+ queue = updatedQueue
+ queueErrorMessage = nil
+ updateNowPlaying()
+ } catch is CancellationError {
+ return
+ } catch {
+ if isCurrentSession(ticket) {
+ queueErrorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ var activeMarkerAction: PlexPlaybackMarkerAction? {
+ switch engine.status {
+ case .playing, .paused, .buffering:
+ return PlexPlaybackMarkerAction.manual(
+ in: presentation.item.markers,
+ at: engine.position,
+ duration: playbackDuration,
+ preferences: settingsStore.playbackMarkerPreferences
+ )
+ case .idle, .preparing, .ended, .failed:
+ return nil
+ }
+ }
+
+ private var markerActionAtCurrentPosition: PlexPlaybackMarkerAction? {
+ switch engine.status {
+ case .playing, .paused, .buffering:
+ PlexPlaybackMarkerAction.active(
+ in: presentation.item.markers,
+ at: engine.position,
+ duration: playbackDuration
+ )
+ case .idle, .preparing, .ended, .failed:
+ nil
+ }
+ }
+
+ func skipActiveMarker() {
+ guard let action = activeMarkerAction else {
+ return
+ }
+ requestSeek(to: action.targetTime)
+ }
+
+ func playbackPositionDidChange() {
+ evaluateAutomaticMarkerSkip()
+ }
+
+ func playbackMarkerPreferencesDidChange() {
+ evaluateAutomaticMarkerSkip()
+ }
+
+ private func evaluateQualitySuggestion() {
+ guard !qualitySuggestionState.isSuppressed,
+ qualitySuggestion == nil else {
+ return
+ }
+ switch engine.status {
+ case .playing, .paused, .buffering:
+ break
+ case .idle, .preparing, .ended, .failed:
+ return
+ }
+
+ let connectionKind = browserStore.connectionStore.activeConnectionKind
+ ?? settingsStore.cachedConnectionKind
+ qualitySuggestion = PlexPlaybackQualitySuggestionPolicy.suggestion(
+ isEnabled: settingsStore.qualitySuggestionsEnabled,
+ selection: videoQualitySelection,
+ sourceBitrate: selectedSourceVideoBitrate,
+ maximumQuality: settingsStore.videoQuality(for: connectionKind),
+ isTranscoding: presentation.plan.method == .transcode,
+ metrics: engine.metricFacts,
+ excludedQualities: qualitySuggestionState.acceptedQualities
+ )
+ }
+
+ private var selectedSourceVideoBitrate: Int? {
+ let mediaIndex = presentation.plan.source.mediaIndex
+ guard presentation.item.media.indices.contains(mediaIndex) else {
+ return nil
+ }
+ return presentation.item.media[mediaIndex].bitrate
+ }
+
+ func selectAudioStream(_ streamID: Int) {
+ guard canChangeMediaSelection,
+ mediaSelection.canSelectAudioStream(streamID),
+ let ticket = currentSessionTicket else {
+ return
+ }
+ userInteractionStore.recordInteraction()
+ Task {
+ await changeMediaSelection(audioStreamID: streamID, ticket: ticket)
+ }
+ }
+
+ func selectSubtitleStream(_ streamID: Int?) {
+ guard canChangeMediaSelection,
+ mediaSelection.canSelectSubtitleStream(streamID),
+ let ticket = currentSessionTicket else {
+ return
+ }
+ userInteractionStore.recordInteraction()
+ Task {
+ await changeMediaSelection(subtitleStreamID: streamID ?? 0, ticket: ticket)
+ }
+ }
+
+ func selectVideoQuality(_ quality: PlexVideoQuality) {
+ guard videoQualitySelection.canSelect(quality),
+ let ticket = currentSessionTicket else {
+ return
+ }
+ qualitySuggestion = nil
+ qualitySuggestionState.suppress()
+ userInteractionStore.recordInteraction()
+ Task {
+ await changeVideoQuality(to: quality, ticket: ticket)
+ }
+ }
+
+ func playbackMetricsDidChange() {
+ persistLastMeasuredBandwidth()
+ evaluateQualitySuggestion()
+ }
+
+ private func persistLastMeasuredBandwidth() {
+ guard let sample = engine.metricFacts?.lastMeasuredBandwidth,
+ let serverIdentifier = presentation.serverIdentifier?.nilIfBlank else {
+ return
+ }
+ Task { [bandwidthRegistry] in
+ do {
+ try await bandwidthRegistry.record(sample, for: serverIdentifier)
+ } catch {
+ plexPlayerLogger.error(
+ "Could not persist playback bandwidth: \(error.localizedDescription, privacy: .public)"
+ )
+ }
+ }
+ }
+
+ func qualitySuggestionSettingsDidChange() {
+ guard settingsStore.qualitySuggestionsEnabled else {
+ qualitySuggestion = nil
+ return
+ }
+ evaluateQualitySuggestion()
+ }
+
+ func acceptQualitySuggestion() {
+ guard let suggestion = qualitySuggestion,
+ let ticket = currentSessionTicket else {
+ return
+ }
+ qualitySuggestion = nil
+ guard videoQualitySelection.canSelect(suggestion.targetQuality) else {
+ qualitySuggestionState.suppress()
+ return
+ }
+
+ qualitySuggestionState.recordAccepted(suggestion.targetQuality)
+ userInteractionStore.recordInteraction()
+ Task {
+ await changeVideoQuality(to: suggestion.targetQuality, ticket: ticket)
+ }
+ }
+
+ func dismissQualitySuggestion() {
+ guard qualitySuggestion != nil else {
+ return
+ }
+ qualitySuggestion = nil
+ qualitySuggestionState.suppress()
+ }
+
+ func selectPlaybackRate(_ playbackRate: PlexPlaybackRate) {
+ guard currentSessionTicket != nil else {
+ return
+ }
+ userInteractionStore.recordInteraction()
+ changePlaybackRate(playbackRate)
+ }
+
+ func updateNativeMediaSelectionAvailability(
+ _ availability: PlexNativeMediaSelectionAvailability,
+ generation: UInt
+ ) {
+ guard nativeMediaSelectionState.accept(
+ availability,
+ generation: generation
+ ) else {
+ return
+ }
+ nowPlayingController.updateLanguageOptions(nowPlayingLanguageOptions)
+ updateServerManagedMediaSelectionControls()
+ }
+
+ func playbackStatusDidChange() {
+ guard !didStop, coordinator.isActive(self) else {
+ return
+ }
+ switch engine.status {
+ case .playing, .paused, .buffering:
+ evaluateQualitySuggestion()
+ case .idle, .preparing, .ended, .failed:
+ qualitySuggestion = nil
+ }
+ evaluateAutomaticMarkerSkip()
+ coordinator.updateTransport(
+ for: self,
+ status: transportStatus,
+ canToggle: !isQueueBusy
+ )
+ updateNowPlaying()
+ }
+
+ func playbackTimeDidJump() {
+ guard let ticket = currentSessionTicket else {
+ return
+ }
+ switch engine.status {
+ case .playing, .paused, .buffering:
+ break
+ case .idle, .preparing, .ended, .failed:
+ return
+ }
+
+ updateNowPlaying(force: true)
+ Task { [weak self] in
+ guard let self,
+ !Task.isCancelled,
+ isCurrentSession(ticket) else {
+ return
+ }
+ await reportTimeline(state: timelineState, ticket: ticket)
+ }
+ }
+
+ func start() async {
+ guard timelineTask == nil else {
+ return
+ }
+
+ let ticket = sessionEpoch.activate()
+ didStop = false
+ qualitySuggestion = nil
+ qualitySuggestionState.beginSession(itemKey: presentation.item.ratingKey)
+ pendingRewindOnResumeRequest = nil
+ automaticMarkerTransition.reset()
+ clearPostPlay()
+ isLoading = true
+ defer {
+ if isCurrentSession(ticket) {
+ isLoading = false
+ }
+ }
+
+ do {
+ canRetryPlayback = false
+ clearNativeMediaSelectionAvailability()
+ try await engine.load(plan: presentation.plan)
+ guard isCurrentSession(ticket) else {
+ engine.stop()
+ return
+ }
+ installNavigationActions()
+ activateNowPlaying()
+ startTimelineReporting(ticket: ticket)
+ } catch is CancellationError {
+ return
+ } catch {
+ guard isCurrentSession(ticket) else {
+ return
+ }
+ canRetryPlayback = false
+ errorMessage = error.localizedDescription
+ clearNowPlayingArtworkLoad()
+ coordinator.clearPlaybackControls(for: self)
+ }
+ }
+
+ func stop(deactivateNowPlaying: Bool = true) {
+ guard didStop == false else {
+ return
+ }
+
+ let stoppedTimeline = PlexTimelineUpdate(
+ ratingKey: presentation.plan.ratingKey,
+ state: .stopped,
+ time: Int(engine.position * 1_000),
+ duration: Int((playbackDuration ?? 0) * 1_000),
+ sessionIdentifier: presentation.plan.sessionIdentifier,
+ playQueueItemID: queue?.currentItem.playQueueItemID,
+ continuing: false
+ )
+ didStop = true
+ qualitySuggestion = nil
+ qualitySuggestionState.reset()
+ automaticMarkerTransition.reset()
+ sessionEpoch.invalidate()
+ canRetryPlayback = false
+ isLoading = false
+ isTransitioning = false
+ isRefreshingQueue = false
+ isChangingShuffle = false
+ isAddingToQueue = false
+ isEditingQueue = false
+ clearPostPlay()
+ timelineTask?.cancel()
+ timelineTask = nil
+ seekTask?.cancel()
+ seekTask = nil
+ pendingRewindOnResumeRequest = nil
+ clearNowPlayingArtworkLoad()
+ engine.stop()
+ if deactivateNowPlaying {
+ nowPlayingController.deactivate()
+ }
+ coordinator.clearPlaybackControls(for: self)
+
+ let timelineReporter = timelineReporter
+ Task {
+ _ = await timelineReporter.report(stoppedTimeline)
+ }
+ }
+
+ private func reportTimelineIfNeeded(ticket: PlexPlaybackSessionEpoch.Ticket) async {
+ guard isCurrentSession(ticket), !Task.isCancelled else {
+ return
+ }
+ coordinator.updateTransport(
+ for: self,
+ status: transportStatus,
+ canToggle: !isQueueBusy
+ )
+ if case .failed(let message) = engine.status {
+ canRetryPlayback = PlexPlaybackRecoveryPolicy.canRetry(
+ status: engine.status,
+ isLoading: isLoading,
+ isActive: coordinator.isActive(self),
+ didStop: didStop
+ )
+ errorMessage = message
+ timelineTask?.cancel()
+ timelineTask = nil
+ clearNowPlayingArtworkLoad()
+ if coordinator.isActive(self) {
+ nowPlayingController.deactivate()
+ coordinator.clearPlaybackControls(for: self)
+ }
+ return
+ }
+
+ if engine.status == .ended {
+ await handlePlaybackEnded(ticket: ticket)
+ return
+ }
+
+ updateNowPlaying()
+ let state = timelineState
+ guard timelineCadence.shouldReport(state: state, at: .now) else {
+ return
+ }
+
+ await reportTimeline(state: state, ticket: ticket)
+ }
+
+ private func reportTimeline(
+ state: PlexTimelineState,
+ continuing: Bool? = nil,
+ time: Int? = nil,
+ ticket: PlexPlaybackSessionEpoch.Ticket
+ ) async {
+ let identity = PlexTimelineRequestIdentity(
+ sessionIdentifier: presentation.plan.sessionIdentifier,
+ ticket: ticket
+ )
+ let update = PlexTimelineUpdate(
+ ratingKey: presentation.plan.ratingKey,
+ state: state,
+ time: time ?? Int(engine.position * 1_000),
+ duration: Int((playbackDuration ?? 0) * 1_000),
+ sessionIdentifier: identity.sessionIdentifier,
+ playQueueItemID: queue?.currentItem.playQueueItemID,
+ continuing: continuing
+ )
+ guard !Task.isCancelled, isCurrentTimelineRequest(identity) else {
+ return
+ }
+ let response = await timelineReporter.report(update)
+ guard !Task.isCancelled, isCurrentTimelineRequest(identity) else {
+ return
+ }
+ if state != .stopped, let termination = response?.termination {
+ handleServerTermination(termination)
+ return
+ }
+ timelineCadence.record(state: state, at: .now)
+ }
+
+ private func handleServerTermination(_ termination: PlexTimelineResponse.Termination) {
+ guard !didStop else {
+ return
+ }
+ didStop = true
+ automaticMarkerTransition.reset()
+ sessionEpoch.invalidate()
+ canRetryPlayback = false
+ isLoading = false
+ isTransitioning = false
+ isRefreshingQueue = false
+ isChangingShuffle = false
+ isAddingToQueue = false
+ isEditingQueue = false
+ clearPostPlay()
+ timelineTask?.cancel()
+ timelineTask = nil
+ seekTask?.cancel()
+ seekTask = nil
+ pendingRewindOnResumeRequest = nil
+ clearNowPlayingArtworkLoad()
+ engine.stop()
+ nowPlayingController.deactivate()
+ coordinator.clearPlaybackControls(for: self)
+ errorMessage = termination.message
+ }
+
+ func retryPlayback() {
+ guard canRetryPlayback, let ticket = currentSessionTicket else {
+ return
+ }
+ Task {
+ await retryFailedPlayback(ticket: ticket)
+ }
+ }
+
+ private func retryFailedPlayback(ticket: PlexPlaybackSessionEpoch.Ticket) async {
+ guard PlexPlaybackRecoveryPolicy.canRetry(
+ status: engine.status,
+ isLoading: isLoading,
+ isActive: coordinator.isActive(self),
+ didStop: didStop
+ ), isCurrentSession(ticket) else {
+ return
+ }
+
+ isLoading = true
+ canRetryPlayback = false
+ errorMessage = nil
+ let position = engine.position
+ let recoveryRequest = PlexPlaybackRecoveryRequest(
+ plan: presentation.plan,
+ videoQuality: presentation.videoQuality,
+ position: position
+ )
+ defer {
+ if isCurrentSession(ticket) {
+ isLoading = false
+ }
+ }
+
+ do {
+ let refreshedItem = try await browserStore.refreshedPlayableDetails(
+ for: presentation.item
+ )
+ try checkCurrentSession(ticket)
+ let plan = try await browserStore.playbackPlan(
+ for: refreshedItem,
+ source: recoveryRequest.source,
+ videoQuality: recoveryRequest.videoQuality,
+ startTimeOverride: recoveryRequest.startTime,
+ forceServerMediaSelection: recoveryRequest.forceServerMediaSelection
+ )
+ try checkCurrentSession(ticket)
+
+ await reportTimeline(
+ state: .stopped,
+ time: Int(recoveryRequest.startTime * 1_000),
+ ticket: ticket
+ )
+ try checkCurrentSession(ticket)
+
+ replacePresentation(PlexPlaybackPresentation(
+ item: refreshedItem,
+ plan: plan,
+ queue: queue,
+ videoQuality: presentation.videoQuality,
+ serverIdentifier: presentation.serverIdentifier,
+ queueSourcePreference: presentation.queueSourcePreference
+ ))
+ handledEndSessionIdentifier = nil
+ timelineCadence.reset()
+ clearNativeMediaSelectionAvailability()
+ try await engine.load(plan: plan)
+ try checkCurrentSession(ticket)
+ installNavigationActions()
+ activateNowPlaying()
+ startTimelineReporting(ticket: ticket)
+ } catch is CancellationError {
+ return
+ } catch {
+ guard isCurrentSession(ticket) else {
+ return
+ }
+ canRetryPlayback = PlexPlaybackRecoveryPolicy.canRetry(
+ status: engine.status,
+ isLoading: false,
+ isActive: coordinator.isActive(self),
+ didStop: didStop
+ )
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ private func startTimelineReporting(ticket: PlexPlaybackSessionEpoch.Ticket) {
+ timelineTask?.cancel()
+ timelineTask = Task { [weak self] in
+ while !Task.isCancelled {
+ guard let self, isCurrentSession(ticket) else {
+ return
+ }
+ await reportTimelineIfNeeded(ticket: ticket)
+ try? await Task.sleep(for: .seconds(1))
+ }
+ }
+ }
+
+ private var timelineState: PlexTimelineState {
+ switch engine.status {
+ case .playing:
+ .playing
+ case .buffering, .preparing:
+ .buffering
+ case .paused:
+ .paused
+ case .idle, .ended, .failed:
+ .stopped
+ }
+ }
+
+ private var transportStatus: PlexPlaybackStatus {
+ pendingRewindOnResumeRequest == nil ? engine.status : .playing
+ }
+
+ private var playbackDuration: TimeInterval? {
+ engine.duration ?? presentation.plan.duration
+ }
+
+ private var isQueueBusy: Bool {
+ isTransitioning
+ || isRefreshingQueue
+ || isChangingShuffle
+ || isAddingToQueue
+ || isEditingQueue
+ }
+
+ private var activeBrowserServerIdentifier: String? {
+ browserStore.connectionStore.activeConnection?.serverID
+ ?? browserStore.connectionStore.settings.selectedServerIdentifier?.nilIfBlank
+ }
+
+ private var currentSessionTicket: PlexPlaybackSessionEpoch.Ticket? {
+ guard !didStop, coordinator.isActive(self) else {
+ return nil
+ }
+ return sessionEpoch.currentTicket()
+ }
+
+ private func isCurrentSession(_ ticket: PlexPlaybackSessionEpoch.Ticket) -> Bool {
+ !didStop && coordinator.isActive(self) && sessionEpoch.isCurrent(ticket)
+ }
+
+ private func isCurrentTimelineRequest(_ identity: PlexTimelineRequestIdentity) -> Bool {
+ !didStop
+ && coordinator.isActive(self)
+ && identity.isCurrent(
+ sessionIdentifier: presentation.plan.sessionIdentifier,
+ epoch: sessionEpoch
+ )
+ }
+
+ private func checkCurrentSession(
+ _ ticket: PlexPlaybackSessionEpoch.Ticket
+ ) throws {
+ try Task.checkCancellation()
+ guard isCurrentSession(ticket) else {
+ throw CancellationError()
+ }
+ }
+
+ private func requestNavigation(_ direction: PlexPlaybackQueueDirection) {
+ guard pendingRewindOnResumeRequest == nil,
+ let ticket = currentSessionTicket else {
+ return
+ }
+ userInteractionStore.recordInteraction()
+ clearPostPlayCountdown()
+ Task {
+ await navigate(
+ direction,
+ completedCurrentItem: false,
+ ticket: ticket
+ )
+ }
+ }
+
+ private var remotePlaybackActions: PlexRemotePlaybackActions {
+ PlexRemotePlaybackActions(
+ play: { [weak self] in
+ self?.performTransportAction(.play)
+ },
+ pause: { [weak self] in
+ self?.performTransportAction(.pause)
+ },
+ togglePlayPause: { [weak self] in
+ self?.togglePlayback()
+ },
+ stop: { [weak self] in
+ self?.requestStop()
+ },
+ seek: { [weak self] position in
+ self?.requestSeek(to: position)
+ },
+ skip: { [weak self] offset in
+ self?.requestSkip(by: offset)
+ },
+ selectAudioStream: { [weak self] streamID in
+ self?.selectAudioStream(streamID)
+ },
+ selectSubtitleStream: { [weak self] streamID in
+ self?.selectSubtitleStream(streamID)
+ },
+ changePlaybackRate: { [weak self] playbackRate in
+ self?.changePlaybackRate(playbackRate)
+ },
+ changeShuffle: { [weak self] isShuffled in
+ self?.setShuffled(isShuffled)
+ },
+ changeRepeatMode: { [weak self] repeatMode in
+ self?.setRepeatMode(repeatMode)
+ },
+ previous: { [weak self] in
+ self?.requestNavigation(.previous)
+ },
+ next: { [weak self] in
+ self?.requestNavigation(.next)
+ }
+ )
+ }
+
+ private func togglePlayback() {
+ guard let action = PlexRewindOnResumePolicy.transportAction(
+ status: engine.status,
+ hasPendingRewind: pendingRewindOnResumeRequest != nil
+ ) else {
+ return
+ }
+ performTransportAction(action)
+ }
+
+ private func performTransportAction(_ action: PlexPlaybackTransportAction) {
+ guard !isQueueBusy,
+ let ticket = currentSessionTicket,
+ PlexRewindOnResumePolicy.transportAction(
+ status: engine.status,
+ hasPendingRewind: pendingRewindOnResumeRequest != nil
+ ) == action else {
+ return
+ }
+ userInteractionStore.recordInteraction()
+
+ switch action {
+ case .play:
+ resumePlayback(ticket: ticket)
+ return
+ case .pause:
+ cancelPendingRewindOnResume()
+ engine.pause()
+ }
+ coordinator.updateTransport(
+ for: self,
+ status: transportStatus,
+ canToggle: true
+ )
+ updateNowPlaying(force: true)
+ }
+
+ private func resumePlayback(ticket: PlexPlaybackSessionEpoch.Ticket) {
+ guard let action = PlexRewindOnResumePolicy.action(
+ status: engine.status,
+ position: engine.position,
+ preference: settingsStore.rewindOnResume
+ ) else {
+ return
+ }
+
+ switch action {
+ case .playImmediately:
+ engine.play()
+ coordinator.updateTransport(
+ for: self,
+ status: engine.status,
+ canToggle: true
+ )
+ updateNowPlaying(force: true)
+
+ case .seekThenPlay(let target):
+ seekTask?.cancel()
+ let request = engine.reserveSeek(to: target)
+ pendingRewindOnResumeRequest = request
+ updatePlaybackControlAvailability()
+ seekTask = Task { [weak self] in
+ guard let self else {
+ return
+ }
+ let completed = await engine.performSeek(request)
+ guard !Task.isCancelled,
+ isCurrentSession(ticket),
+ pendingRewindOnResumeRequest == request else {
+ return
+ }
+
+ pendingRewindOnResumeRequest = nil
+ seekTask = nil
+ if !completed {
+ engine.cancelPendingSeek()
+ }
+ engine.play()
+ updatePlaybackControlAvailability()
+ updateNowPlaying(force: true)
+ await reportTimeline(state: timelineState, ticket: ticket)
+ }
+ }
+ }
+
+ private func cancelPendingRewindOnResume() {
+ guard pendingRewindOnResumeRequest != nil else {
+ return
+ }
+ pendingRewindOnResumeRequest = nil
+ seekTask?.cancel()
+ seekTask = nil
+ engine.cancelPendingSeek()
+ updatePlaybackControlAvailability()
+ }
+
+ private func requestStop() {
+ guard currentSessionTicket != nil else {
+ return
+ }
+ userInteractionStore.recordInteraction()
+ coordinator.close(self)
+ }
+
+ private func requestSeek(to position: TimeInterval) {
+ guard let ticket = currentSessionTicket else {
+ return
+ }
+ userInteractionStore.recordInteraction()
+ cancelPendingRewindOnResume()
+ performSeek(engine.reserveSeek(to: position), ticket: ticket)
+ }
+
+ private func evaluateAutomaticMarkerSkip() {
+ guard !didStop, coordinator.isActive(self), let ticket = currentSessionTicket else {
+ automaticMarkerTransition.reset()
+ return
+ }
+
+ guard let action = automaticMarkerTransition.action(
+ for: markerActionAtCurrentPosition,
+ preferences: settingsStore.playbackMarkerPreferences
+ ) else {
+ return
+ }
+
+ performSeek(
+ engine.reserveSeek(to: action.targetTime),
+ ticket: ticket,
+ automaticMarkerAction: action
+ )
+ }
+
+ private func requestSkip(by offset: TimeInterval) {
+ guard !isQueueBusy, let ticket = currentSessionTicket else {
+ return
+ }
+ userInteractionStore.recordInteraction()
+ cancelPendingRewindOnResume()
+ performSeek(
+ engine.reserveSkip(
+ by: offset,
+ duration: playbackDuration
+ ),
+ ticket: ticket
+ )
+ }
+
+ private func performSeek(
+ _ request: PlexPlaybackSeekSequence.Request,
+ ticket: PlexPlaybackSessionEpoch.Ticket,
+ automaticMarkerAction: PlexPlaybackMarkerAction? = nil
+ ) {
+ seekTask?.cancel()
+ seekTask = Task { [weak self] in
+ guard let self else {
+ return
+ }
+ let completed = await engine.performSeek(request)
+ guard completed, !Task.isCancelled, isCurrentSession(ticket) else {
+ if let automaticMarkerAction {
+ automaticMarkerTransition.retry(automaticMarkerAction)
+ }
+ return
+ }
+ updateNowPlaying(force: true)
+ await reportTimeline(state: timelineState, ticket: ticket)
+ }
+ }
+
+ private func changePlaybackRate(_ playbackRate: PlexPlaybackRate) {
+ engine.setPlaybackRate(playbackRate)
+ coordinator.updatePlaybackRate(for: self, playbackRate: playbackRate)
+ updateNowPlaying(force: true)
+ }
+
+ private func changeMediaSelection(
+ audioStreamID: Int? = nil,
+ subtitleStreamID: Int? = nil,
+ ticket: PlexPlaybackSessionEpoch.Ticket
+ ) async {
+ let policy = PlexPlaybackReconfigurationPolicy(status: engine.status)
+ guard canChangeMediaSelection, policy.canReload,
+ let partID = mediaSelection.partID,
+ isCurrentSession(ticket) else {
+ return
+ }
+
+ isTransitioning = true
+ isLoading = true
+ updatePlaybackControlAvailability()
+ defer {
+ if isCurrentSession(ticket) {
+ isLoading = false
+ isTransitioning = false
+ updatePlaybackControlAvailability()
+ }
+ }
+
+ if policy.autoplay {
+ engine.pause()
+ updateNowPlaying(force: true)
+ }
+ let position = engine.position
+ var committedReload = false
+ do {
+ try await browserStore.selectMediaStreams(
+ partID: partID,
+ audioStreamID: audioStreamID,
+ subtitleStreamID: subtitleStreamID
+ )
+ try checkCurrentSession(ticket)
+ let refreshedItem = try await browserStore.refreshedPlayableDetails(
+ for: presentation.item
+ )
+ try checkCurrentSession(ticket)
+ let plan = try await browserStore.playbackPlan(
+ for: refreshedItem,
+ source: presentation.plan.source,
+ videoQuality: presentation.videoQuality,
+ startTimeOverride: position,
+ forceServerMediaSelection: true
+ )
+ try checkCurrentSession(ticket)
+
+ await reportTimeline(
+ state: .stopped,
+ time: Int(position * 1_000),
+ ticket: ticket
+ )
+ try checkCurrentSession(ticket)
+ committedReload = true
+ replacePresentation(PlexPlaybackPresentation(
+ item: refreshedItem,
+ plan: plan,
+ queue: queue,
+ videoQuality: presentation.videoQuality,
+ serverIdentifier: presentation.serverIdentifier,
+ queueSourcePreference: presentation.queueSourcePreference
+ ))
+ handledEndSessionIdentifier = nil
+ timelineCadence.reset()
+ clearNativeMediaSelectionAvailability()
+ try await engine.load(plan: plan, autoplay: policy.autoplay)
+ try checkCurrentSession(ticket)
+ activateNowPlaying()
+ } catch is CancellationError {
+ return
+ } catch {
+ if !committedReload, policy.autoplay, isCurrentSession(ticket) {
+ engine.play()
+ updateNowPlaying(force: true)
+ }
+ if isCurrentSession(ticket) {
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ private func changeVideoQuality(
+ to quality: PlexVideoQuality,
+ ticket: PlexPlaybackSessionEpoch.Ticket
+ ) async {
+ let policy = PlexPlaybackReconfigurationPolicy(status: engine.status)
+ guard videoQualitySelection.canSelect(quality), policy.canReload,
+ isCurrentSession(ticket) else {
+ return
+ }
+
+ isTransitioning = true
+ isLoading = true
+ updatePlaybackControlAvailability()
+ defer {
+ if isCurrentSession(ticket) {
+ isLoading = false
+ isTransitioning = false
+ updatePlaybackControlAvailability()
+ }
+ }
+
+ if policy.autoplay {
+ engine.pause()
+ updateNowPlaying(force: true)
+ }
+ let position = engine.position
+ var committedReload = false
+ do {
+ let plan = try await browserStore.playbackPlan(
+ for: presentation.item,
+ source: presentation.plan.source,
+ videoQuality: quality,
+ startTimeOverride: position
+ )
+ try checkCurrentSession(ticket)
+
+ await reportTimeline(
+ state: .stopped,
+ time: Int(position * 1_000),
+ ticket: ticket
+ )
+ try checkCurrentSession(ticket)
+ committedReload = true
+ replacePresentation(PlexPlaybackPresentation(
+ item: presentation.item,
+ plan: plan,
+ queue: queue,
+ videoQuality: quality,
+ serverIdentifier: presentation.serverIdentifier,
+ queueSourcePreference: presentation.queueSourcePreference
+ ))
+ handledEndSessionIdentifier = nil
+ timelineCadence.reset()
+ clearNativeMediaSelectionAvailability()
+ try await engine.load(plan: plan, autoplay: policy.autoplay)
+ try checkCurrentSession(ticket)
+ activateNowPlaying()
+ } catch is CancellationError {
+ return
+ } catch {
+ if !committedReload, policy.autoplay, isCurrentSession(ticket) {
+ engine.play()
+ updateNowPlaying(force: true)
+ }
+ if isCurrentSession(ticket) {
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ private func updateNowPlaying(force: Bool = false) {
+ nowPlayingController.update(
+ metadata: nowPlayingMetadata,
+ status: engine.status,
+ force: force
+ )
+ }
+
+ private func activateNowPlaying() {
+ clearNowPlayingArtworkLoad()
+ nowPlayingController.activate(
+ metadata: nowPlayingMetadata,
+ status: engine.status,
+ actions: remotePlaybackActions,
+ canGoPrevious: queue?.canMovePrevious == true,
+ canGoNext: queue?.canMoveNext == true,
+ canSeek: !isQueueBusy,
+ languageOptions: nowPlayingLanguageOptions,
+ canChangeLanguageOptions: canChangeLanguageOptions,
+ canChangeShuffle: queue?.canChangeShuffle == true && !isQueueBusy,
+ isShuffled: queue?.isShuffled == true,
+ repeatMode: repeatMode,
+ canRepeatAll: queue?.canRepeatAll == true
+ )
+ updatePlaybackControlAvailability()
+ beginNowPlayingArtworkLoad()
+ }
+
+ private func beginNowPlayingArtworkLoad() {
+ guard let request = PlexNowPlayingArtworkRequest(
+ item: presentation.item,
+ serverURL: artworkServerURL,
+ token: artworkToken,
+ clientContext: artworkClientContext
+ ) else {
+ return
+ }
+
+ let sessionIdentifier = presentation.plan.sessionIdentifier
+ let itemRatingKey = presentation.item.ratingKey
+ let loader = nowPlayingArtworkLoader
+ nowPlayingArtworkTask = Task { [weak self] in
+ guard let image = await loader.load(request),
+ let self,
+ !Task.isCancelled,
+ coordinator.isActive(self),
+ presentation.plan.sessionIdentifier == sessionIdentifier else {
+ return
+ }
+ nowPlayingController.updateArtwork(
+ image,
+ itemRatingKey: itemRatingKey
+ )
+ }
+ }
+
+ private func clearNowPlayingArtworkLoad() {
+ nowPlayingArtworkTask?.cancel()
+ nowPlayingArtworkTask = nil
+ nowPlayingArtworkLoader.invalidate()
+ }
+
+ private func clearNativeMediaSelectionAvailability() {
+ let hadAvailability = nativeMediaSelectionState.availability != nil
+ nativeMediaSelectionState.beginReload()
+ updateServerManagedMediaSelectionControls()
+ guard hadAvailability else {
+ return
+ }
+ nowPlayingController.updateLanguageOptions(nowPlayingLanguageOptions)
+ }
+
+ private func installNavigationActions() {
+ coordinator.installTransport(
+ for: self,
+ status: engine.status,
+ canToggle: !isQueueBusy,
+ toggle: { [weak self] in
+ self?.togglePlayback()
+ },
+ stop: { [weak self] in
+ self?.requestStop()
+ }
+ )
+ coordinator.installNavigation(
+ for: self,
+ previous: { [weak self] in
+ self?.requestNavigation(.previous)
+ },
+ next: { [weak self] in
+ self?.requestNavigation(.next)
+ }
+ )
+ coordinator.installPlaybackRate(
+ for: self,
+ playbackRate: engine.playbackRate,
+ action: { [weak self] playbackRate in
+ self?.changePlaybackRate(playbackRate)
+ }
+ )
+ coordinator.installVideoQuality(
+ for: self,
+ selection: videoQualitySelection,
+ action: { [weak self] quality in
+ self?.selectVideoQuality(quality)
+ }
+ )
+ coordinator.installServerManagedMediaSelection(
+ for: self,
+ selection: serverManagedMediaSelection,
+ canChange: canChangeMediaSelection,
+ selectAudioStream: { [weak self] streamID in
+ self?.selectAudioStream(streamID)
+ },
+ selectSubtitleStream: { [weak self] streamID in
+ self?.selectSubtitleStream(streamID)
+ }
+ )
+ coordinator.installSeeking(
+ for: self,
+ canSeek: !isQueueBusy,
+ action: { [weak self] offset in
+ self?.requestSkip(by: offset)
+ }
+ )
+ coordinator.installShuffle(
+ for: self,
+ isShuffled: queue?.isShuffled == true,
+ canChange: queue?.canChangeShuffle == true && !isQueueBusy,
+ action: { [weak self] isShuffled in
+ self?.setShuffled(isShuffled)
+ }
+ )
+ coordinator.installRepeatMode(
+ for: self,
+ repeatMode: repeatMode,
+ canRepeatAll: queue?.canRepeatAll == true,
+ action: { [weak self] repeatMode in
+ self?.setRepeatMode(repeatMode)
+ }
+ )
+ updatePlaybackControlAvailability()
+ }
+
+ private func updatePlaybackControlAvailability() {
+ let hasPendingRewind = pendingRewindOnResumeRequest != nil
+ let canGoPrevious = queue?.canMovePrevious == true && !isQueueBusy && !hasPendingRewind
+ let canGoNext = queue?.canMoveNext == true && !isQueueBusy && !hasPendingRewind
+ let canChangeShuffle = queue?.canChangeShuffle == true && !isQueueBusy && !hasPendingRewind
+ let isShuffled = queue?.isShuffled == true
+ let canRepeatAll = queue?.canRepeatAll == true
+ coordinator.updateTransport(
+ for: self,
+ status: transportStatus,
+ canToggle: !isQueueBusy
+ )
+ coordinator.updateNavigation(
+ for: self,
+ canGoPrevious: canGoPrevious,
+ canGoNext: canGoNext
+ )
+ coordinator.updateSeeking(for: self, canSeek: !isQueueBusy && !hasPendingRewind)
+ coordinator.updateVideoQuality(for: self, selection: videoQualitySelection)
+ updateServerManagedMediaSelectionControls()
+ nowPlayingController.updateNavigation(
+ canGoPrevious: canGoPrevious,
+ canGoNext: canGoNext
+ )
+ nowPlayingController.updateSeeking(canSeek: !isQueueBusy)
+ nowPlayingController.updateLanguageCommandAvailability(
+ canChange: canChangeLanguageOptions
+ )
+ coordinator.updateShuffle(
+ for: self,
+ isShuffled: isShuffled,
+ canChange: canChangeShuffle
+ )
+ nowPlayingController.updateShuffle(
+ canChange: canChangeShuffle,
+ isShuffled: isShuffled
+ )
+ coordinator.updateRepeatMode(
+ for: self,
+ repeatMode: repeatMode,
+ canRepeatAll: canRepeatAll
+ )
+ coordinator.updateQueueInsertion(
+ for: self,
+ canAdd: queue != nil
+ && presentation.serverIdentifier?.nilIfBlank == activeBrowserServerIdentifier
+ && !isQueueBusy,
+ isAdding: isAddingToQueue
+ )
+ nowPlayingController.updateRepeatMode(repeatMode)
+ }
+
+ private func handlePlaybackEnded(ticket: PlexPlaybackSessionEpoch.Ticket) async {
+ guard !isQueueBusy, isCurrentSession(ticket) else {
+ return
+ }
+ let sessionIdentifier = presentation.plan.sessionIdentifier
+ guard handledEndSessionIdentifier != sessionIdentifier else {
+ return
+ }
+ handledEndSessionIdentifier = sessionIdentifier
+
+ let completionAction = PlexPlaybackCompletionAction.resolve(
+ repeatMode: repeatMode,
+ canAdvance: queue?.canMoveNext == true,
+ canResetQueue: queue?.canRepeatAll == true,
+ completedItem: presentation.item,
+ mediaKind: presentation.plan.mediaKind,
+ duration: playbackDuration,
+ autoplayPreferences: settingsStore.autoplayPreferences,
+ lastInteractionDate: userInteractionStore.lastInteractionDate,
+ isCinemaPreplayItem: queue?.isCurrentCinemaPreplayItem == true
+ )
+ switch completionAction {
+ case .replayCurrent:
+ await replayCurrentItem(ticket: ticket)
+ return
+ case .advanceNext:
+ await navigate(
+ .next,
+ completedCurrentItem: true,
+ ticket: ticket
+ )
+ return
+ case .presentPostPlay(let autoAdvanceAfterSeconds):
+ await presentQueuedPostPlay(
+ autoAdvanceAfterSeconds: autoAdvanceAfterSeconds,
+ ticket: ticket
+ )
+ return
+ case .resetQueue:
+ await resetQueueAndContinue(ticket: ticket)
+ return
+ case .stop:
+ break
+ }
+
+ await finishCurrentItem(continuing: false, ticket: ticket)
+ if isCurrentSession(ticket) {
+ await loadPostPlay(ticket: ticket)
+ }
+ }
+
+ private func presentQueuedPostPlay(
+ autoAdvanceAfterSeconds: Int?,
+ ticket: PlexPlaybackSessionEpoch.Ticket
+ ) async {
+ do {
+ let nextItem = try await resolveNextQueueItemForPostPlay(ticket: ticket)
+ try checkCurrentSession(ticket)
+ await finishCurrentItem(continuing: true, ticket: ticket)
+ try checkCurrentSession(ticket)
+
+ postPlayNextItem = nextItem
+ startPostPlayCountdown(
+ seconds: autoAdvanceAfterSeconds,
+ ticket: ticket
+ )
+ Task { [weak self] in
+ await self?.loadPostPlay(ticket: ticket)
+ }
+ } catch is CancellationError {
+ return
+ } catch {
+ guard isCurrentSession(ticket) else {
+ return
+ }
+ errorMessage = error.localizedDescription
+ await finishCurrentItem(continuing: false, ticket: ticket)
+ if isCurrentSession(ticket) {
+ await loadPostPlay(ticket: ticket)
+ }
+ }
+ }
+
+ private func resolveNextQueueItemForPostPlay(
+ ticket: PlexPlaybackSessionEpoch.Ticket
+ ) async throws -> PlexMediaItem {
+ guard var updatedQueue = queue, updatedQueue.canMoveNext else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+ if updatedQueue.needsWindowRefresh(for: .next) {
+ guard let currentQueueItemID = updatedQueue.currentItem.playQueueItemID else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+ let page = try await browserStore.refreshPlayQueueWindow(
+ queueID: updatedQueue.id,
+ centeredOn: currentQueueItemID
+ )
+ try checkCurrentSession(ticket)
+ try updatedQueue.replaceWindow(with: page, centeredOn: currentQueueItemID)
+ }
+ guard let nextItem = updatedQueue.presentation.upcomingItems.first else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+ queue = updatedQueue
+ queueErrorMessage = nil
+ updatePlaybackControlAvailability()
+ return nextItem
+ }
+
+ private func finishCurrentItem(
+ continuing: Bool,
+ ticket: PlexPlaybackSessionEpoch.Ticket
+ ) async {
+ await reportTimeline(
+ state: .stopped,
+ continuing: continuing,
+ time: Int((playbackDuration ?? engine.position) * 1_000),
+ ticket: ticket
+ )
+ guard !isOfflinePlayback else {
+ if isCurrentSession(ticket) {
+ updateNowPlaying(force: true)
+ }
+ return
+ }
+ do {
+ try checkCurrentSession(ticket)
+ try await browserStore.markPlayedIfSupported(ratingKey: presentation.item.ratingKey)
+ try checkCurrentSession(ticket)
+ } catch is CancellationError {
+ return
+ } catch {
+ if isCurrentSession(ticket) {
+ errorMessage = error.localizedDescription
+ }
+ }
+ if isCurrentSession(ticket) {
+ updateNowPlaying(force: true)
+ }
+ }
+
+ private func startPostPlayCountdown(
+ seconds: Int?,
+ ticket: PlexPlaybackSessionEpoch.Ticket
+ ) {
+ clearPostPlayCountdown()
+ guard let seconds, seconds > 0 else {
+ return
+ }
+ postPlayCountdownTotalSeconds = seconds
+ postPlayCountdownRemainingSeconds = seconds
+ postPlayCountdownTask = Task { [weak self] in
+ guard let self else {
+ return
+ }
+ for remaining in stride(from: seconds - 1, through: 0, by: -1) {
+ try? await Task.sleep(for: .seconds(1))
+ guard !Task.isCancelled,
+ isCurrentSession(ticket),
+ engine.status == .ended,
+ settingsStore.autoplayUpNext else {
+ if !Task.isCancelled {
+ clearPostPlayCountdown()
+ }
+ return
+ }
+ postPlayCountdownRemainingSeconds = remaining
+ }
+ postPlayCountdownTask = nil
+ postPlayCountdownTotalSeconds = nil
+ postPlayCountdownRemainingSeconds = nil
+ await navigate(
+ .next,
+ completedCurrentItem: false,
+ ticket: ticket
+ )
+ }
+ }
+
+ private func loadPostPlay(ticket: PlexPlaybackSessionEpoch.Ticket) async {
+ guard canShowCurrentItemInLibrary,
+ isCurrentSession(ticket), engine.status == .ended, !isLoadingPostPlay else {
+ return
+ }
+ let itemTicket = PlexPlayerItemMutationTicket(presentation: presentation)
+ isLoadingPostPlay = true
+ postPlayErrorMessage = nil
+ defer {
+ if isCurrentSession(ticket), itemTicket.accepts(presentation) {
+ isLoadingPostPlay = false
+ }
+ }
+
+ do {
+ let hubs = try await browserStore.postPlayHubs(for: presentation.item, count: 8)
+ try checkCurrentSession(ticket)
+ guard canShowCurrentItemInLibrary,
+ engine.status == .ended,
+ itemTicket.accepts(presentation) else {
+ return
+ }
+ postPlayHubs = Array(hubs.prefix(4))
+ postPlayErrorMessage = nil
+ } catch is CancellationError {
+ return
+ } catch {
+ guard canShowCurrentItemInLibrary,
+ isCurrentSession(ticket),
+ engine.status == .ended,
+ itemTicket.accepts(presentation) else {
+ return
+ }
+ postPlayErrorMessage = error.localizedDescription
+ }
+ }
+
+ private func startPostPlayItem(
+ _ selectedItem: PlexMediaItem,
+ ticket: PlexPlaybackSessionEpoch.Ticket
+ ) async {
+ guard canShowCurrentItemInLibrary,
+ !isQueueBusy, isCurrentSession(ticket), engine.status == .ended else {
+ return
+ }
+
+ isTransitioning = true
+ isLoading = true
+ updatePlaybackControlAvailability()
+ defer {
+ if isCurrentSession(ticket) {
+ isLoading = false
+ isTransitioning = false
+ updatePlaybackControlAvailability()
+ }
+ }
+
+ do {
+ let nextItem = try await browserStore.refreshedPlayableDetails(for: selectedItem)
+ try checkCurrentSession(ticket)
+ async let plan = browserStore.playbackPlan(
+ for: nextItem,
+ videoQuality: presentation.videoQuality
+ )
+ async let nextQueue = postPlayQueue(for: nextItem)
+ let (resolvedPlan, resolvedQueue) = try await (plan, nextQueue)
+ try checkCurrentSession(ticket)
+ guard canShowCurrentItemInLibrary else {
+ return
+ }
+
+ queue = resolvedQueue
+ queueErrorMessage = nil
+ replacePresentation(PlexPlaybackPresentation(
+ item: nextItem,
+ plan: resolvedPlan,
+ queue: resolvedQueue,
+ videoQuality: presentation.videoQuality,
+ serverIdentifier: presentation.serverIdentifier
+ ))
+ timelineCadence.reset()
+ handledEndSessionIdentifier = nil
+ clearNativeMediaSelectionAvailability()
+ try await engine.load(plan: resolvedPlan)
+ try checkCurrentSession(ticket)
+ installNavigationActions()
+ activateNowPlaying()
+ } catch is CancellationError {
+ return
+ } catch {
+ if isCurrentSession(ticket) {
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ private func postPlayQueue(for item: PlexMediaItem) async throws -> PlexPlaybackQueue? {
+ guard item.continuousPlayQueueType != nil else {
+ return nil
+ }
+ return try await browserStore.continuousPlayQueue(for: item)
+ }
+
+ private func replayCurrentItem(ticket: PlexPlaybackSessionEpoch.Ticket) async {
+ guard !isQueueBusy, isCurrentSession(ticket) else {
+ return
+ }
+
+ isTransitioning = true
+ isLoading = true
+ updatePlaybackControlAvailability()
+ defer {
+ if isCurrentSession(ticket) {
+ isLoading = false
+ isTransitioning = false
+ updatePlaybackControlAvailability()
+ }
+ }
+
+ do {
+ try await transition(
+ to: presentation.item,
+ queue: queue,
+ completedCurrentItem: true,
+ startTimeOverride: 0,
+ ticket: ticket
+ )
+ } catch is CancellationError {
+ return
+ } catch {
+ if isCurrentSession(ticket) {
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ private func resetQueueAndContinue(ticket: PlexPlaybackSessionEpoch.Ticket) async {
+ guard !isQueueBusy, isCurrentSession(ticket),
+ var updatedQueue = queue, updatedQueue.canRepeatAll else {
+ return
+ }
+
+ isTransitioning = true
+ isLoading = true
+ updatePlaybackControlAvailability()
+ defer {
+ if isCurrentSession(ticket) {
+ isLoading = false
+ isTransitioning = false
+ updatePlaybackControlAvailability()
+ }
+ }
+
+ do {
+ let page = try await browserStore.resetPlayQueue(queueID: updatedQueue.id)
+ try checkCurrentSession(ticket)
+ try updatedQueue.applyReset(page)
+ try await transition(
+ to: updatedQueue.currentItem,
+ queue: updatedQueue,
+ completedCurrentItem: true,
+ startTimeOverride: 0,
+ ticket: ticket
+ )
+ } catch is CancellationError {
+ return
+ } catch {
+ if isCurrentSession(ticket) {
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ private func navigate(
+ _ direction: PlexPlaybackQueueDirection,
+ completedCurrentItem: Bool,
+ ticket: PlexPlaybackSessionEpoch.Ticket
+ ) async {
+ guard !isQueueBusy, isCurrentSession(ticket),
+ var updatedQueue = queue, updatedQueue.canMove(direction) else {
+ return
+ }
+
+ isTransitioning = true
+ isLoading = true
+ updatePlaybackControlAvailability()
+ defer {
+ if isCurrentSession(ticket) {
+ isLoading = false
+ isTransitioning = false
+ updatePlaybackControlAvailability()
+ }
+ }
+
+ do {
+ if updatedQueue.needsWindowRefresh(for: direction) {
+ guard let currentQueueItemID = updatedQueue.currentItem.playQueueItemID else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+ let page = try await browserStore.refreshPlayQueueWindow(
+ queueID: updatedQueue.id,
+ centeredOn: currentQueueItemID
+ )
+ try checkCurrentSession(ticket)
+ try updatedQueue.replaceWindow(with: page, centeredOn: currentQueueItemID)
+ }
+ guard let queuedItem = updatedQueue.move(direction) else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+
+ try await transition(
+ to: queuedItem,
+ queue: updatedQueue,
+ completedCurrentItem: completedCurrentItem,
+ startTimeOverride: (
+ updatedQueue.isCinemaPreplayQueue || direction == .previous
+ ) ? 0 : nil,
+ ticket: ticket
+ )
+ } catch is CancellationError {
+ return
+ } catch {
+ if isCurrentSession(ticket) {
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ private func navigate(
+ toPlayQueueItemID playQueueItemID: String,
+ ticket: PlexPlaybackSessionEpoch.Ticket
+ ) async {
+ guard !isQueueBusy, isCurrentSession(ticket),
+ var updatedQueue = queue,
+ updatedQueue.presentation.upcomingItems.contains(where: {
+ $0.playQueueItemID == playQueueItemID
+ }),
+ let queuedItem = updatedQueue.move(toPlayQueueItemID: playQueueItemID) else {
+ return
+ }
+
+ isTransitioning = true
+ isLoading = true
+ updatePlaybackControlAvailability()
+ defer {
+ if isCurrentSession(ticket) {
+ isLoading = false
+ isTransitioning = false
+ updatePlaybackControlAvailability()
+ }
+ }
+
+ do {
+ try await transition(
+ to: queuedItem,
+ queue: updatedQueue,
+ completedCurrentItem: false,
+ startTimeOverride: updatedQueue.isCinemaPreplayQueue ? 0 : nil,
+ ticket: ticket
+ )
+ } catch is CancellationError {
+ return
+ } catch {
+ if isCurrentSession(ticket) {
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ private func transition(
+ to queuedItem: PlexMediaItem,
+ queue updatedQueue: PlexPlaybackQueue?,
+ completedCurrentItem: Bool,
+ startTimeOverride: TimeInterval?,
+ ticket: PlexPlaybackSessionEpoch.Ticket
+ ) async throws {
+ let nextItem = try await browserStore.refreshedPlayableDetails(for: queuedItem)
+ try checkCurrentSession(ticket)
+ let nextPlan = try await browserStore.playbackPlan(
+ for: nextItem,
+ source: presentation.queueSourcePreference?.source(for: nextItem),
+ videoQuality: presentation.videoQuality,
+ startTimeOverride: startTimeOverride
+ )
+ try checkCurrentSession(ticket)
+
+ let completedTime = Int((playbackDuration ?? engine.position) * 1_000)
+ await reportTimeline(
+ state: .stopped,
+ continuing: true,
+ time: completedCurrentItem ? completedTime : nil,
+ ticket: ticket
+ )
+ try checkCurrentSession(ticket)
+ if completedCurrentItem, queue?.isCurrentCinemaPreplayItem != true {
+ try await browserStore.markPlayedIfSupported(ratingKey: presentation.item.ratingKey)
+ try checkCurrentSession(ticket)
+ }
+
+ queue = updatedQueue
+ queueErrorMessage = nil
+ replacePresentation(PlexPlaybackPresentation(
+ item: nextItem,
+ plan: nextPlan,
+ queue: updatedQueue,
+ videoQuality: presentation.videoQuality,
+ serverIdentifier: presentation.serverIdentifier,
+ queueSourcePreference: presentation.queueSourcePreference
+ ))
+ timelineCadence.reset()
+ handledEndSessionIdentifier = nil
+ clearNativeMediaSelectionAvailability()
+ try await engine.load(plan: nextPlan)
+ try checkCurrentSession(ticket)
+ activateNowPlaying()
+ }
+
+ private var nowPlayingMetadata: PlexNowPlayingMetadata {
+ let queuePresentation = queue?.presentation
+ return PlexNowPlayingMetadata(
+ item: presentation.item,
+ duration: playbackDuration,
+ elapsedTime: engine.position,
+ playbackRate: engine.status == .playing ? Double(engine.player.rate) : 0,
+ defaultPlaybackRate: Double(engine.playbackRate.rawValue),
+ serverIdentifier: presentation.serverIdentifier,
+ queuePosition: queuePresentation?.currentPosition,
+ queueCount: queuePresentation?.totalCount
+ )
+ }
+
+ private func updateServerManagedMediaSelectionControls() {
+ coordinator.updateServerManagedMediaSelection(
+ for: self,
+ selection: serverManagedMediaSelection,
+ canChange: canChangeMediaSelection
+ )
+ }
+
+
+ private var nowPlayingLanguageOptions: PlexNowPlayingLanguageOptions {
+ PlexNowPlayingLanguageOptions(
+ selection: mediaSelection,
+ nativeAvailability: nativeMediaSelectionState.availability
+ )
+ }
+
+ private var canChangeLanguageOptions: Bool {
+ canChangeMediaSelection
+ }
+}
+
+private extension PlexPlaybackStatus {
+ var label: String {
+ switch self {
+ case .idle: "Idle"
+ case .preparing: "Preparing"
+ case .playing: "Playing"
+ case .paused: "Paused"
+ case .buffering: "Buffering"
+ case .ended: "Ended"
+ case .failed: "Failed"
+ }
+ }
+}
diff --git a/PlexBar/Playback/PlexTimelineReportCadence.swift b/PlexBar/Playback/PlexTimelineReportCadence.swift
new file mode 100644
index 0000000..3e24676
--- /dev/null
+++ b/PlexBar/Playback/PlexTimelineReportCadence.swift
@@ -0,0 +1,62 @@
+import Foundation
+
+struct PlexTimelineReportCadence {
+ static let defaultReportingInterval: Duration = .seconds(10)
+
+ let reportingInterval: Duration
+ private(set) var lastReportedState: PlexTimelineState?
+ private(set) var lastReportInstant: ContinuousClock.Instant?
+
+ init(reportingInterval: Duration = Self.defaultReportingInterval) {
+ self.reportingInterval = reportingInterval
+ }
+
+ func shouldReport(
+ state: PlexTimelineState,
+ at instant: ContinuousClock.Instant
+ ) -> Bool {
+ guard let lastReportedState, let lastReportInstant else {
+ return true
+ }
+ return state != lastReportedState
+ || lastReportInstant.duration(to: instant) >= reportingInterval
+ }
+
+ mutating func record(
+ state: PlexTimelineState,
+ at instant: ContinuousClock.Instant
+ ) {
+ lastReportedState = state
+ lastReportInstant = instant
+ }
+
+ mutating func reset() {
+ lastReportedState = nil
+ lastReportInstant = nil
+ }
+}
+
+@MainActor
+final class PlexTimelineReportSequencer {
+ typealias ReportOperation = @MainActor (PlexTimelineUpdate) async -> PlexTimelineResponse?
+
+ private let reportOperation: ReportOperation
+ private var tail: Task?
+
+ init(reportOperation: @escaping ReportOperation) {
+ self.reportOperation = reportOperation
+ }
+
+ func report(_ update: PlexTimelineUpdate) async -> PlexTimelineResponse? {
+ let previous = tail
+ let reportOperation = reportOperation
+ let task = Task { @MainActor in
+ if let previous {
+ _ = await previous.value
+ }
+ return await reportOperation(update)
+ }
+ tail = task
+ return await task.value
+ }
+}
diff --git a/PlexBar/Playback/PlexTimelineRequestParameters.swift b/PlexBar/Playback/PlexTimelineRequestParameters.swift
new file mode 100644
index 0000000..1e1c5dd
--- /dev/null
+++ b/PlexBar/Playback/PlexTimelineRequestParameters.swift
@@ -0,0 +1,47 @@
+import PlexModels
+import Foundation
+
+struct PlexTimelineRequestParameters {
+ let update: PlexTimelineUpdate
+
+ var queryItems: [URLQueryItem] {
+ var items = [
+ URLQueryItem(name: "key", value: "/library/metadata/\(update.ratingKey)"),
+ URLQueryItem(name: "ratingKey", value: update.ratingKey),
+ URLQueryItem(name: "state", value: update.state.rawValue),
+ URLQueryItem(name: "time", value: String(max(update.time, 0))),
+ URLQueryItem(name: "duration", value: String(max(update.duration, 0)))
+ ]
+ if let playQueueItemID = update.playQueueItemID?.nilIfBlank {
+ items.append(URLQueryItem(name: "playQueueItemID", value: playQueueItemID))
+ }
+ if update.state == .stopped, let continuing = update.continuing {
+ items.append(URLQueryItem(name: "continuing", value: continuing ? "1" : "0"))
+ }
+ if update.offline {
+ items.append(URLQueryItem(name: "offline", value: "1"))
+ }
+ return items
+ }
+}
+
+struct PlexWatchedStateRequestParameters {
+ let endpointPath: String
+ let queryItems: [URLQueryItem]
+
+ init(
+ watched: Bool,
+ ratingKey: String,
+ endpoints: PlexLibraryProviderEndpoints
+ ) throws {
+ let endpointPath = watched ? endpoints.scrobblePath : endpoints.unscrobblePath
+ guard let endpointPath else {
+ throw PlexAPIError.missingLibraryTimelineFeature
+ }
+ self.endpointPath = endpointPath
+ queryItems = [
+ URLQueryItem(name: "identifier", value: endpoints.providerIdentifier),
+ URLQueryItem(name: "key", value: ratingKey),
+ ]
+ }
+}
diff --git a/PlexBar/Playback/PlexVideoFullScreenKeyboardHandler.swift b/PlexBar/Playback/PlexVideoFullScreenKeyboardHandler.swift
new file mode 100644
index 0000000..b3f67f6
--- /dev/null
+++ b/PlexBar/Playback/PlexVideoFullScreenKeyboardHandler.swift
@@ -0,0 +1,107 @@
+import AppKit
+import AVKit
+import OSLog
+
+@MainActor
+final class PlexVideoFullScreenKeyboardHandler {
+ enum Action {
+ case enter
+ case exit
+
+ var selector: Selector {
+ // AVKit exposes the button and delegate callbacks, but these actions
+ // are undocumented. Keep these selectors isolated from playback logic.
+ switch self {
+ case .enter: NSSelectorFromString("enterFullScreen:")
+ case .exit: NSSelectorFromString("exitFullScreen:")
+ }
+ }
+ }
+
+ private weak var playerView: AVPlayerView?
+ private let lifecycle: PlexPlayerPresentationLifecycle
+ private var eventMonitor: Any?
+ private let logger = Logger(subsystem: AppConstants.bundleIdentifier, category: "VideoFullScreen")
+
+ init(playerView: AVPlayerView, lifecycle: PlexPlayerPresentationLifecycle) {
+ self.playerView = playerView
+ self.lifecycle = lifecycle
+ }
+
+ func start() {
+ guard eventMonitor == nil else { return }
+ eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
+ let handled = MainActor.assumeIsolated {
+ guard let self else { return false }
+ return self.handleKeyDown(event) == nil
+ }
+ return handled ? nil : event
+ }
+ }
+
+ func stop() {
+ if let eventMonitor {
+ NSEvent.removeMonitor(eventMonitor)
+ }
+ eventMonitor = nil
+ }
+
+ func handleKeyDown(_ event: NSEvent) -> NSEvent? {
+ guard let playerView,
+ playerView.showsFullScreenToggleButton,
+ playerView.player?.currentItem?.status == .readyToPlay,
+ let window = event.window,
+ window.isKeyWindow,
+ window.attachedSheet == nil,
+ NSApp.modalWindow == nil,
+ !lifecycle.isPictureInPictureActive else {
+ return event
+ }
+
+ // AVKit leaves the original AVPlayerView in its host window and presents
+ // the video in a separate fullscreen window owned by the same app.
+ guard window === playerView.window
+ || (lifecycle.isFullScreenActive && window.styleMask.contains(.fullScreen)) else {
+ return event
+ }
+
+ let isEditingText = (window.firstResponder as? NSTextView)?.isEditable == true
+ guard let action = Self.action(
+ for: event,
+ isFullScreenActive: lifecycle.isFullScreenActive,
+ isEditingText: isEditingText
+ ) else {
+ return event
+ }
+
+ // Consume repeats and presses during animation without starting a second
+ // transition or allowing Escape to invoke Back to Library.
+ guard !event.isARepeat, !lifecycle.isFullScreenTransitioning else { return nil }
+ guard playerView.responds(to: action.selector) else {
+ logger.error("AVKit does not support the video fullscreen action: \(NSStringFromSelector(action.selector), privacy: .public)")
+ NSSound.beep()
+ return nil
+ }
+ playerView.perform(action.selector, with: nil)
+ return nil
+ }
+
+ static func action(
+ for event: NSEvent,
+ isFullScreenActive: Bool,
+ isEditingText: Bool
+ ) -> Action? {
+ guard !isEditingText,
+ event.modifierFlags.intersection(.deviceIndependentFlagsMask)
+ .subtracting(.capsLock).isEmpty else { return nil }
+
+ switch event.charactersIgnoringModifiers?.lowercased() {
+ case "f":
+ return isFullScreenActive ? .exit : .enter
+ case "\u{1b}" where isFullScreenActive:
+ return .exit
+ default:
+ return nil
+ }
+ }
+}
diff --git a/PlexBar/Playback/PlexVideoPreparationStage.swift b/PlexBar/Playback/PlexVideoPreparationStage.swift
new file mode 100644
index 0000000..ab48f03
--- /dev/null
+++ b/PlexBar/Playback/PlexVideoPreparationStage.swift
@@ -0,0 +1,81 @@
+import PlexModels
+import SwiftUI
+
+enum PlexVideoPreparationPolicy {
+ static func shouldPresent(
+ mediaKind: PlexPlaybackMediaKind,
+ status: PlexPlaybackStatus,
+ isLoading: Bool
+ ) -> Bool {
+ guard mediaKind == .video else {
+ return false
+ }
+
+ switch status {
+ case .idle:
+ return isLoading
+ case .preparing:
+ return true
+ case .playing, .paused, .buffering, .ended, .failed:
+ return false
+ }
+ }
+
+}
+
+struct PlexVideoPreparationStage: View {
+ let title: String
+ let primaryImageURL: URL?
+ let fallbackImageURL: URL?
+ let token: String
+ let clientContext: PlexClientContext
+
+ init(
+ item: PlexMediaItem,
+ serverURL: URL?,
+ token: String,
+ clientContext: PlexClientContext
+ ) {
+ title = item.title
+ let artworkPaths = item.nowPlayingArtworkPaths
+ primaryImageURL = serverURL.flatMap { serverURL in
+ PlexURLBuilder.mediaURL(serverURL: serverURL, path: artworkPaths.first)
+ }
+ fallbackImageURL = serverURL.flatMap { serverURL in
+ PlexURLBuilder.mediaURL(
+ serverURL: serverURL,
+ path: artworkPaths.dropFirst().first
+ )
+ }
+ self.token = token
+ self.clientContext = clientContext
+ }
+
+ var body: some View {
+ ZStack {
+ PlexArtworkBackdrop(
+ primaryImageURL: primaryImageURL,
+ fallbackImageURL: fallbackImageURL,
+ token: token,
+ clientContext: clientContext
+ )
+
+ VStack(spacing: 12) {
+ ProgressView()
+ .controlSize(.large)
+
+ Text(title)
+ .font(.title2.bold())
+ .lineLimit(2)
+ .multilineTextAlignment(.center)
+
+ Text("Preparing…")
+ .foregroundStyle(.secondary)
+ }
+ .scenePadding()
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel("Preparing \(title)")
+ }
+ .allowsHitTesting(false)
+ }
+}
diff --git a/AppIcon.icon/Assets/ribbon-balloon.png b/PlexBar/Resources/AppIcon.icon/Assets/ribbon-balloon.png
similarity index 100%
rename from AppIcon.icon/Assets/ribbon-balloon.png
rename to PlexBar/Resources/AppIcon.icon/Assets/ribbon-balloon.png
diff --git a/AppIcon.icon/Assets/ribbon-fuzzy-brand.png b/PlexBar/Resources/AppIcon.icon/Assets/ribbon-fuzzy-brand.png
similarity index 100%
rename from AppIcon.icon/Assets/ribbon-fuzzy-brand.png
rename to PlexBar/Resources/AppIcon.icon/Assets/ribbon-fuzzy-brand.png
diff --git a/AppIcon.icon/Assets/ribbon-fuzzy.png b/PlexBar/Resources/AppIcon.icon/Assets/ribbon-fuzzy.png
similarity index 100%
rename from AppIcon.icon/Assets/ribbon-fuzzy.png
rename to PlexBar/Resources/AppIcon.icon/Assets/ribbon-fuzzy.png
diff --git a/AppIcon.icon/Assets/ribbon.png b/PlexBar/Resources/AppIcon.icon/Assets/ribbon.png
similarity index 100%
rename from AppIcon.icon/Assets/ribbon.png
rename to PlexBar/Resources/AppIcon.icon/Assets/ribbon.png
diff --git a/AppIcon.icon/icon.json b/PlexBar/Resources/AppIcon.icon/icon.json
similarity index 100%
rename from AppIcon.icon/icon.json
rename to PlexBar/Resources/AppIcon.icon/icon.json
diff --git a/Sources/PlexBar/Resources/MenuBarIcon/MenuBarIcon.png b/PlexBar/Resources/MenuBarIcon/MenuBarIcon.png
similarity index 100%
rename from Sources/PlexBar/Resources/MenuBarIcon/MenuBarIcon.png
rename to PlexBar/Resources/MenuBarIcon/MenuBarIcon.png
diff --git a/Sources/PlexBar/Resources/MenuBarIcon/MenuBarIcon@2x.png b/PlexBar/Resources/MenuBarIcon/MenuBarIcon@2x.png
similarity index 100%
rename from Sources/PlexBar/Resources/MenuBarIcon/MenuBarIcon@2x.png
rename to PlexBar/Resources/MenuBarIcon/MenuBarIcon@2x.png
diff --git a/Sources/PlexBar/Resources/MockServer/art/audiobooks/dracula.png b/PlexBar/Resources/MockServer/art/audiobooks/dracula/cover.png
similarity index 100%
rename from Sources/PlexBar/Resources/MockServer/art/audiobooks/dracula.png
rename to PlexBar/Resources/MockServer/art/audiobooks/dracula/cover.png
diff --git a/Sources/PlexBar/Resources/MockServer/art/audiobooks/originals/dracula.png b/PlexBar/Resources/MockServer/art/audiobooks/dracula/masters/cover.png
similarity index 100%
rename from Sources/PlexBar/Resources/MockServer/art/audiobooks/originals/dracula.png
rename to PlexBar/Resources/MockServer/art/audiobooks/dracula/masters/cover.png
diff --git a/Sources/PlexBar/Resources/MockServer/art/audiobooks/the-time-machine.png b/PlexBar/Resources/MockServer/art/audiobooks/the-time-machine/cover.png
similarity index 100%
rename from Sources/PlexBar/Resources/MockServer/art/audiobooks/the-time-machine.png
rename to PlexBar/Resources/MockServer/art/audiobooks/the-time-machine/cover.png
diff --git a/Sources/PlexBar/Resources/MockServer/art/audiobooks/originals/the-time-machine.png b/PlexBar/Resources/MockServer/art/audiobooks/the-time-machine/masters/cover.png
similarity index 100%
rename from Sources/PlexBar/Resources/MockServer/art/audiobooks/originals/the-time-machine.png
rename to PlexBar/Resources/MockServer/art/audiobooks/the-time-machine/masters/cover.png
diff --git a/Sources/PlexBar/Resources/MockServer/art/audiobooks/war-of-the-worlds.png b/PlexBar/Resources/MockServer/art/audiobooks/war-of-the-worlds/cover.png
similarity index 100%
rename from Sources/PlexBar/Resources/MockServer/art/audiobooks/war-of-the-worlds.png
rename to PlexBar/Resources/MockServer/art/audiobooks/war-of-the-worlds/cover.png
diff --git a/Sources/PlexBar/Resources/MockServer/art/audiobooks/originals/war-of-the-worlds.png b/PlexBar/Resources/MockServer/art/audiobooks/war-of-the-worlds/masters/cover.png
similarity index 100%
rename from Sources/PlexBar/Resources/MockServer/art/audiobooks/originals/war-of-the-worlds.png
rename to PlexBar/Resources/MockServer/art/audiobooks/war-of-the-worlds/masters/cover.png
diff --git a/PlexBar/Resources/MockServer/art/movies/a-star-is-born/backdrop.jpg b/PlexBar/Resources/MockServer/art/movies/a-star-is-born/backdrop.jpg
new file mode 100644
index 0000000..0217642
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/a-star-is-born/backdrop.jpg differ
diff --git a/PlexBar/Resources/MockServer/art/movies/a-star-is-born/poster.png b/PlexBar/Resources/MockServer/art/movies/a-star-is-born/poster.png
new file mode 100644
index 0000000..7736d4d
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/a-star-is-born/poster.png differ
diff --git a/PlexBar/Resources/MockServer/art/movies/all-quiet-on-the-western-front/backdrop.jpg b/PlexBar/Resources/MockServer/art/movies/all-quiet-on-the-western-front/backdrop.jpg
new file mode 100644
index 0000000..3dab714
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/all-quiet-on-the-western-front/backdrop.jpg differ
diff --git a/PlexBar/Resources/MockServer/art/movies/all-quiet-on-the-western-front/poster.png b/PlexBar/Resources/MockServer/art/movies/all-quiet-on-the-western-front/poster.png
new file mode 100644
index 0000000..f0f5ab5
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/all-quiet-on-the-western-front/poster.png differ
diff --git a/PlexBar/Resources/MockServer/art/movies/animal-crackers/backdrop.jpg b/PlexBar/Resources/MockServer/art/movies/animal-crackers/backdrop.jpg
new file mode 100644
index 0000000..cb45bb8
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/animal-crackers/backdrop.jpg differ
diff --git a/PlexBar/Resources/MockServer/art/movies/animal-crackers/poster.png b/PlexBar/Resources/MockServer/art/movies/animal-crackers/poster.png
new file mode 100644
index 0000000..c0db7a1
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/animal-crackers/poster.png differ
diff --git a/PlexBar/Resources/MockServer/art/movies/charade/backdrop.jpg b/PlexBar/Resources/MockServer/art/movies/charade/backdrop.jpg
new file mode 100644
index 0000000..74fb490
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/charade/backdrop.jpg differ
diff --git a/Sources/PlexBar/Resources/MockServer/art/movies/originals/charade.png b/PlexBar/Resources/MockServer/art/movies/charade/masters/poster.png
similarity index 100%
rename from Sources/PlexBar/Resources/MockServer/art/movies/originals/charade.png
rename to PlexBar/Resources/MockServer/art/movies/charade/masters/poster.png
diff --git a/Sources/PlexBar/Resources/MockServer/art/movies/charade.png b/PlexBar/Resources/MockServer/art/movies/charade/poster.png
similarity index 100%
rename from Sources/PlexBar/Resources/MockServer/art/movies/charade.png
rename to PlexBar/Resources/MockServer/art/movies/charade/poster.png
diff --git a/PlexBar/Resources/MockServer/art/movies/fear-and-desire/backdrop.jpg b/PlexBar/Resources/MockServer/art/movies/fear-and-desire/backdrop.jpg
new file mode 100644
index 0000000..4aecfe6
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/fear-and-desire/backdrop.jpg differ
diff --git a/PlexBar/Resources/MockServer/art/movies/fear-and-desire/poster.png b/PlexBar/Resources/MockServer/art/movies/fear-and-desire/poster.png
new file mode 100644
index 0000000..515332d
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/fear-and-desire/poster.png differ
diff --git a/PlexBar/Resources/MockServer/art/movies/its-a-wonderful-life/backdrop.jpg b/PlexBar/Resources/MockServer/art/movies/its-a-wonderful-life/backdrop.jpg
new file mode 100644
index 0000000..b363d60
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/its-a-wonderful-life/backdrop.jpg differ
diff --git a/PlexBar/Resources/MockServer/art/movies/its-a-wonderful-life/poster.png b/PlexBar/Resources/MockServer/art/movies/its-a-wonderful-life/poster.png
new file mode 100644
index 0000000..bd2a4c8
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/its-a-wonderful-life/poster.png differ
diff --git a/PlexBar/Resources/MockServer/art/movies/metropolis/backdrop.jpg b/PlexBar/Resources/MockServer/art/movies/metropolis/backdrop.jpg
new file mode 100644
index 0000000..d8b8362
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/metropolis/backdrop.jpg differ
diff --git a/PlexBar/Resources/MockServer/art/movies/metropolis/poster.png b/PlexBar/Resources/MockServer/art/movies/metropolis/poster.png
new file mode 100644
index 0000000..2813384
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/metropolis/poster.png differ
diff --git a/PlexBar/Resources/MockServer/art/movies/my-man-godfrey/backdrop.jpg b/PlexBar/Resources/MockServer/art/movies/my-man-godfrey/backdrop.jpg
new file mode 100644
index 0000000..0575914
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/my-man-godfrey/backdrop.jpg differ
diff --git a/PlexBar/Resources/MockServer/art/movies/my-man-godfrey/poster.png b/PlexBar/Resources/MockServer/art/movies/my-man-godfrey/poster.png
new file mode 100644
index 0000000..37ab16c
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/my-man-godfrey/poster.png differ
diff --git a/PlexBar/Resources/MockServer/art/movies/night-of-the-living-dead/backdrop.jpg b/PlexBar/Resources/MockServer/art/movies/night-of-the-living-dead/backdrop.jpg
new file mode 100644
index 0000000..322100b
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/night-of-the-living-dead/backdrop.jpg differ
diff --git a/Sources/PlexBar/Resources/MockServer/art/movies/originals/night-of-the-living-dead.png b/PlexBar/Resources/MockServer/art/movies/night-of-the-living-dead/masters/poster.png
similarity index 100%
rename from Sources/PlexBar/Resources/MockServer/art/movies/originals/night-of-the-living-dead.png
rename to PlexBar/Resources/MockServer/art/movies/night-of-the-living-dead/masters/poster.png
diff --git a/Sources/PlexBar/Resources/MockServer/art/movies/night-of-the-living-dead.png b/PlexBar/Resources/MockServer/art/movies/night-of-the-living-dead/poster.png
similarity index 100%
rename from Sources/PlexBar/Resources/MockServer/art/movies/night-of-the-living-dead.png
rename to PlexBar/Resources/MockServer/art/movies/night-of-the-living-dead/poster.png
diff --git a/PlexBar/Resources/MockServer/art/movies/nosferatu/backdrop.jpg b/PlexBar/Resources/MockServer/art/movies/nosferatu/backdrop.jpg
new file mode 100644
index 0000000..7584b26
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/nosferatu/backdrop.jpg differ
diff --git a/PlexBar/Resources/MockServer/art/movies/nosferatu/poster.png b/PlexBar/Resources/MockServer/art/movies/nosferatu/poster.png
new file mode 100644
index 0000000..50d200c
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/nosferatu/poster.png differ
diff --git a/PlexBar/Resources/MockServer/art/movies/plan-9-from-outer-space/backdrop.jpg b/PlexBar/Resources/MockServer/art/movies/plan-9-from-outer-space/backdrop.jpg
new file mode 100644
index 0000000..deb783b
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/plan-9-from-outer-space/backdrop.jpg differ
diff --git a/PlexBar/Resources/MockServer/art/movies/plan-9-from-outer-space/poster.png b/PlexBar/Resources/MockServer/art/movies/plan-9-from-outer-space/poster.png
new file mode 100644
index 0000000..a74e591
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/plan-9-from-outer-space/poster.png differ
diff --git a/PlexBar/Resources/MockServer/art/movies/reefer-madness/backdrop.jpg b/PlexBar/Resources/MockServer/art/movies/reefer-madness/backdrop.jpg
new file mode 100644
index 0000000..f706ae5
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/reefer-madness/backdrop.jpg differ
diff --git a/PlexBar/Resources/MockServer/art/movies/reefer-madness/poster.png b/PlexBar/Resources/MockServer/art/movies/reefer-madness/poster.png
new file mode 100644
index 0000000..48b32b0
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/reefer-madness/poster.png differ
diff --git a/PlexBar/Resources/MockServer/art/movies/sherlock-jr/backdrop.jpg b/PlexBar/Resources/MockServer/art/movies/sherlock-jr/backdrop.jpg
new file mode 100644
index 0000000..8bff935
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/sherlock-jr/backdrop.jpg differ
diff --git a/Sources/PlexBar/Resources/MockServer/art/movies/originals/sherlock-jr.png b/PlexBar/Resources/MockServer/art/movies/sherlock-jr/masters/poster.png
similarity index 100%
rename from Sources/PlexBar/Resources/MockServer/art/movies/originals/sherlock-jr.png
rename to PlexBar/Resources/MockServer/art/movies/sherlock-jr/masters/poster.png
diff --git a/Sources/PlexBar/Resources/MockServer/art/movies/sherlock-jr.png b/PlexBar/Resources/MockServer/art/movies/sherlock-jr/poster.png
similarity index 100%
rename from Sources/PlexBar/Resources/MockServer/art/movies/sherlock-jr.png
rename to PlexBar/Resources/MockServer/art/movies/sherlock-jr/poster.png
diff --git a/PlexBar/Resources/MockServer/art/movies/the-bat/backdrop.jpg b/PlexBar/Resources/MockServer/art/movies/the-bat/backdrop.jpg
new file mode 100644
index 0000000..1b04059
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/the-bat/backdrop.jpg differ
diff --git a/PlexBar/Resources/MockServer/art/movies/the-bat/poster.png b/PlexBar/Resources/MockServer/art/movies/the-bat/poster.png
new file mode 100644
index 0000000..e1984d6
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/the-bat/poster.png differ
diff --git a/PlexBar/Resources/MockServer/art/movies/the-general/backdrop.jpg b/PlexBar/Resources/MockServer/art/movies/the-general/backdrop.jpg
new file mode 100644
index 0000000..372dcb0
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/the-general/backdrop.jpg differ
diff --git a/PlexBar/Resources/MockServer/art/movies/the-general/poster.png b/PlexBar/Resources/MockServer/art/movies/the-general/poster.png
new file mode 100644
index 0000000..5f3c79f
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/the-general/poster.png differ
diff --git a/PlexBar/Resources/MockServer/art/movies/the-last-man-on-earth/backdrop.jpg b/PlexBar/Resources/MockServer/art/movies/the-last-man-on-earth/backdrop.jpg
new file mode 100644
index 0000000..ea277fe
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/the-last-man-on-earth/backdrop.jpg differ
diff --git a/PlexBar/Resources/MockServer/art/movies/the-last-man-on-earth/poster.png b/PlexBar/Resources/MockServer/art/movies/the-last-man-on-earth/poster.png
new file mode 100644
index 0000000..1aee1c8
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/the-last-man-on-earth/poster.png differ
diff --git a/PlexBar/Resources/MockServer/art/movies/the-little-shop-of-horrors/backdrop.jpg b/PlexBar/Resources/MockServer/art/movies/the-little-shop-of-horrors/backdrop.jpg
new file mode 100644
index 0000000..0e691da
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/the-little-shop-of-horrors/backdrop.jpg differ
diff --git a/PlexBar/Resources/MockServer/art/movies/the-little-shop-of-horrors/poster.png b/PlexBar/Resources/MockServer/art/movies/the-little-shop-of-horrors/poster.png
new file mode 100644
index 0000000..0a7ba51
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/the-little-shop-of-horrors/poster.png differ
diff --git a/PlexBar/Resources/MockServer/art/movies/the-lost-world/backdrop.jpg b/PlexBar/Resources/MockServer/art/movies/the-lost-world/backdrop.jpg
new file mode 100644
index 0000000..21790a3
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/the-lost-world/backdrop.jpg differ
diff --git a/PlexBar/Resources/MockServer/art/movies/the-lost-world/poster.png b/PlexBar/Resources/MockServer/art/movies/the-lost-world/poster.png
new file mode 100644
index 0000000..d2ed4d0
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/the-lost-world/poster.png differ
diff --git a/PlexBar/Resources/MockServer/art/movies/the-phantom-of-the-opera/backdrop.jpg b/PlexBar/Resources/MockServer/art/movies/the-phantom-of-the-opera/backdrop.jpg
new file mode 100644
index 0000000..b53e560
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/the-phantom-of-the-opera/backdrop.jpg differ
diff --git a/PlexBar/Resources/MockServer/art/movies/the-phantom-of-the-opera/poster.png b/PlexBar/Resources/MockServer/art/movies/the-phantom-of-the-opera/poster.png
new file mode 100644
index 0000000..5ec8a11
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/the-phantom-of-the-opera/poster.png differ
diff --git a/PlexBar/Resources/MockServer/art/movies/the-stranger/backdrop.jpg b/PlexBar/Resources/MockServer/art/movies/the-stranger/backdrop.jpg
new file mode 100644
index 0000000..87f7cb5
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/the-stranger/backdrop.jpg differ
diff --git a/PlexBar/Resources/MockServer/art/movies/the-stranger/poster.png b/PlexBar/Resources/MockServer/art/movies/the-stranger/poster.png
new file mode 100644
index 0000000..6b213f3
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/movies/the-stranger/poster.png differ
diff --git a/PlexBar/Resources/MockServer/art/studio/2104/backdrop.jpg b/PlexBar/Resources/MockServer/art/studio/2104/backdrop.jpg
new file mode 100644
index 0000000..f1f907b
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/studio/2104/backdrop.jpg differ
diff --git a/PlexBar/Resources/MockServer/art/studio/2104/poster.png b/PlexBar/Resources/MockServer/art/studio/2104/poster.png
new file mode 100644
index 0000000..32138cc
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/studio/2104/poster.png differ
diff --git a/PlexBar/Resources/MockServer/art/studio/2105/backdrop.jpg b/PlexBar/Resources/MockServer/art/studio/2105/backdrop.jpg
new file mode 100644
index 0000000..24411ad
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/studio/2105/backdrop.jpg differ
diff --git a/PlexBar/Resources/MockServer/art/studio/2105/poster.png b/PlexBar/Resources/MockServer/art/studio/2105/poster.png
new file mode 100644
index 0000000..9d0f75e
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/studio/2105/poster.png differ
diff --git a/PlexBar/Resources/MockServer/art/studio/2106/backdrop.jpg b/PlexBar/Resources/MockServer/art/studio/2106/backdrop.jpg
new file mode 100644
index 0000000..439dc07
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/studio/2106/backdrop.jpg differ
diff --git a/PlexBar/Resources/MockServer/art/studio/2106/poster.png b/PlexBar/Resources/MockServer/art/studio/2106/poster.png
new file mode 100644
index 0000000..2837851
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/studio/2106/poster.png differ
diff --git a/PlexBar/Resources/MockServer/art/studio/2107/backdrop.jpg b/PlexBar/Resources/MockServer/art/studio/2107/backdrop.jpg
new file mode 100644
index 0000000..6a15ae0
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/studio/2107/backdrop.jpg differ
diff --git a/PlexBar/Resources/MockServer/art/studio/2107/poster.png b/PlexBar/Resources/MockServer/art/studio/2107/poster.png
new file mode 100644
index 0000000..bb32547
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/studio/2107/poster.png differ
diff --git a/PlexBar/Resources/MockServer/art/studio/2108/backdrop.jpg b/PlexBar/Resources/MockServer/art/studio/2108/backdrop.jpg
new file mode 100644
index 0000000..49d1471
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/studio/2108/backdrop.jpg differ
diff --git a/PlexBar/Resources/MockServer/art/studio/2108/poster.png b/PlexBar/Resources/MockServer/art/studio/2108/poster.png
new file mode 100644
index 0000000..a1ce1ea
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/studio/2108/poster.png differ
diff --git a/PlexBar/Resources/MockServer/art/studio/2109/backdrop.jpg b/PlexBar/Resources/MockServer/art/studio/2109/backdrop.jpg
new file mode 100644
index 0000000..5c63f77
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/studio/2109/backdrop.jpg differ
diff --git a/PlexBar/Resources/MockServer/art/studio/2109/poster.png b/PlexBar/Resources/MockServer/art/studio/2109/poster.png
new file mode 100644
index 0000000..ee7e8f8
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/studio/2109/poster.png differ
diff --git a/PlexBar/Resources/MockServer/art/studio/2110/backdrop.jpg b/PlexBar/Resources/MockServer/art/studio/2110/backdrop.jpg
new file mode 100644
index 0000000..ce81b43
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/studio/2110/backdrop.jpg differ
diff --git a/PlexBar/Resources/MockServer/art/studio/2110/poster.png b/PlexBar/Resources/MockServer/art/studio/2110/poster.png
new file mode 100644
index 0000000..7ae930f
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/studio/2110/poster.png differ
diff --git a/PlexBar/Resources/MockServer/art/studio/2111/backdrop.jpg b/PlexBar/Resources/MockServer/art/studio/2111/backdrop.jpg
new file mode 100644
index 0000000..bb095f7
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/studio/2111/backdrop.jpg differ
diff --git a/PlexBar/Resources/MockServer/art/studio/2111/poster.png b/PlexBar/Resources/MockServer/art/studio/2111/poster.png
new file mode 100644
index 0000000..b394e2f
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/studio/2111/poster.png differ
diff --git a/PlexBar/Resources/MockServer/art/studio/2112/backdrop.jpg b/PlexBar/Resources/MockServer/art/studio/2112/backdrop.jpg
new file mode 100644
index 0000000..4dcb816
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/studio/2112/backdrop.jpg differ
diff --git a/PlexBar/Resources/MockServer/art/studio/2112/poster.png b/PlexBar/Resources/MockServer/art/studio/2112/poster.png
new file mode 100644
index 0000000..1ca3fc4
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/studio/2112/poster.png differ
diff --git a/PlexBar/Resources/MockServer/art/studio/3104/cover.png b/PlexBar/Resources/MockServer/art/studio/3104/cover.png
new file mode 100644
index 0000000..4914f79
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/studio/3104/cover.png differ
diff --git a/PlexBar/Resources/MockServer/art/studio/3105/cover.png b/PlexBar/Resources/MockServer/art/studio/3105/cover.png
new file mode 100644
index 0000000..77f5107
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/studio/3105/cover.png differ
diff --git a/PlexBar/Resources/MockServer/art/studio/3106/cover.png b/PlexBar/Resources/MockServer/art/studio/3106/cover.png
new file mode 100644
index 0000000..9efb6fa
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/studio/3106/cover.png differ
diff --git a/PlexBar/Resources/MockServer/art/studio/3107/cover.png b/PlexBar/Resources/MockServer/art/studio/3107/cover.png
new file mode 100644
index 0000000..1e45165
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/studio/3107/cover.png differ
diff --git a/PlexBar/Resources/MockServer/art/studio/3108/cover.png b/PlexBar/Resources/MockServer/art/studio/3108/cover.png
new file mode 100644
index 0000000..3d2c7b9
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/studio/3108/cover.png differ
diff --git a/PlexBar/Resources/MockServer/art/studio/3109/cover.png b/PlexBar/Resources/MockServer/art/studio/3109/cover.png
new file mode 100644
index 0000000..f941fc3
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/studio/3109/cover.png differ
diff --git a/PlexBar/Resources/MockServer/art/studio/3110/cover.png b/PlexBar/Resources/MockServer/art/studio/3110/cover.png
new file mode 100644
index 0000000..8886603
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/studio/3110/cover.png differ
diff --git a/PlexBar/Resources/MockServer/art/tv/abbott-and-costello/backdrop.jpg b/PlexBar/Resources/MockServer/art/tv/abbott-and-costello/backdrop.jpg
new file mode 100644
index 0000000..d780b77
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/tv/abbott-and-costello/backdrop.jpg differ
diff --git a/Sources/PlexBar/Resources/MockServer/art/tv/originals/abbott-and-costello.png b/PlexBar/Resources/MockServer/art/tv/abbott-and-costello/masters/poster.png
similarity index 100%
rename from Sources/PlexBar/Resources/MockServer/art/tv/originals/abbott-and-costello.png
rename to PlexBar/Resources/MockServer/art/tv/abbott-and-costello/masters/poster.png
diff --git a/PlexBar/Resources/MockServer/art/tv/abbott-and-costello/poster.png b/PlexBar/Resources/MockServer/art/tv/abbott-and-costello/poster.png
new file mode 100644
index 0000000..7d1a58a
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/tv/abbott-and-costello/poster.png differ
diff --git a/PlexBar/Resources/MockServer/art/tv/adventures-of-ozzie-and-harriet/backdrop.jpg b/PlexBar/Resources/MockServer/art/tv/adventures-of-ozzie-and-harriet/backdrop.jpg
new file mode 100644
index 0000000..38a4c71
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/tv/adventures-of-ozzie-and-harriet/backdrop.jpg differ
diff --git a/PlexBar/Resources/MockServer/art/tv/adventures-of-ozzie-and-harriet/masters/poster.png b/PlexBar/Resources/MockServer/art/tv/adventures-of-ozzie-and-harriet/masters/poster.png
new file mode 100644
index 0000000..2ccf5cf
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/tv/adventures-of-ozzie-and-harriet/masters/poster.png differ
diff --git a/PlexBar/Resources/MockServer/art/tv/adventures-of-ozzie-and-harriet/poster.png b/PlexBar/Resources/MockServer/art/tv/adventures-of-ozzie-and-harriet/poster.png
new file mode 100644
index 0000000..f4a1244
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/tv/adventures-of-ozzie-and-harriet/poster.png differ
diff --git a/PlexBar/Resources/MockServer/art/tv/one-step-beyond/backdrop.jpg b/PlexBar/Resources/MockServer/art/tv/one-step-beyond/backdrop.jpg
new file mode 100644
index 0000000..d51b47e
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/tv/one-step-beyond/backdrop.jpg differ
diff --git a/Sources/PlexBar/Resources/MockServer/art/tv/originals/one-step-beyond.png b/PlexBar/Resources/MockServer/art/tv/one-step-beyond/masters/poster.png
similarity index 100%
rename from Sources/PlexBar/Resources/MockServer/art/tv/originals/one-step-beyond.png
rename to PlexBar/Resources/MockServer/art/tv/one-step-beyond/masters/poster.png
diff --git a/PlexBar/Resources/MockServer/art/tv/one-step-beyond/poster.png b/PlexBar/Resources/MockServer/art/tv/one-step-beyond/poster.png
new file mode 100644
index 0000000..457985c
Binary files /dev/null and b/PlexBar/Resources/MockServer/art/tv/one-step-beyond/poster.png differ
diff --git a/Sources/PlexBar/Resources/MockServer/avatars/dana-scully.png b/PlexBar/Resources/MockServer/avatars/dana-scully.png
similarity index 100%
rename from Sources/PlexBar/Resources/MockServer/avatars/dana-scully.png
rename to PlexBar/Resources/MockServer/avatars/dana-scully.png
diff --git a/Sources/PlexBar/Resources/MockServer/avatars/darlene-alderson.png b/PlexBar/Resources/MockServer/avatars/darlene-alderson.png
similarity index 100%
rename from Sources/PlexBar/Resources/MockServer/avatars/darlene-alderson.png
rename to PlexBar/Resources/MockServer/avatars/darlene-alderson.png
diff --git a/Sources/PlexBar/Resources/MockServer/avatars/elliot-alderson.png b/PlexBar/Resources/MockServer/avatars/elliot-alderson.png
similarity index 100%
rename from Sources/PlexBar/Resources/MockServer/avatars/elliot-alderson.png
rename to PlexBar/Resources/MockServer/avatars/elliot-alderson.png
diff --git a/PlexBar/Resources/MockServer/avatars/joi.png b/PlexBar/Resources/MockServer/avatars/joi.png
new file mode 100644
index 0000000..e6b3495
Binary files /dev/null and b/PlexBar/Resources/MockServer/avatars/joi.png differ
diff --git a/Sources/PlexBar/Resources/MockServer/avatars/le-petit-prince.png b/PlexBar/Resources/MockServer/avatars/le-petit-prince.png
similarity index 100%
rename from Sources/PlexBar/Resources/MockServer/avatars/le-petit-prince.png
rename to PlexBar/Resources/MockServer/avatars/le-petit-prince.png
diff --git a/PlexBar/Resources/MockServer/avatars/le0n.png b/PlexBar/Resources/MockServer/avatars/le0n.png
new file mode 100644
index 0000000..740bceb
Binary files /dev/null and b/PlexBar/Resources/MockServer/avatars/le0n.png differ
diff --git a/Sources/PlexBar/Resources/MockServer/avatars/originals/dana-scully.png b/PlexBar/Resources/MockServer/avatars/originals/dana-scully.png
similarity index 100%
rename from Sources/PlexBar/Resources/MockServer/avatars/originals/dana-scully.png
rename to PlexBar/Resources/MockServer/avatars/originals/dana-scully.png
diff --git a/Sources/PlexBar/Resources/MockServer/avatars/originals/darlene-alderson.png b/PlexBar/Resources/MockServer/avatars/originals/darlene-alderson.png
similarity index 100%
rename from Sources/PlexBar/Resources/MockServer/avatars/originals/darlene-alderson.png
rename to PlexBar/Resources/MockServer/avatars/originals/darlene-alderson.png
diff --git a/Sources/PlexBar/Resources/MockServer/avatars/originals/elliot-alderson.png b/PlexBar/Resources/MockServer/avatars/originals/elliot-alderson.png
similarity index 100%
rename from Sources/PlexBar/Resources/MockServer/avatars/originals/elliot-alderson.png
rename to PlexBar/Resources/MockServer/avatars/originals/elliot-alderson.png
diff --git a/Sources/PlexBar/Resources/MockServer/avatars/originals/le-petit-prince.png b/PlexBar/Resources/MockServer/avatars/originals/le-petit-prince.png
similarity index 100%
rename from Sources/PlexBar/Resources/MockServer/avatars/originals/le-petit-prince.png
rename to PlexBar/Resources/MockServer/avatars/originals/le-petit-prince.png
diff --git a/Sources/PlexBar/Resources/MockServer/avatars/originals/popeye.png b/PlexBar/Resources/MockServer/avatars/originals/popeye.png
similarity index 100%
rename from Sources/PlexBar/Resources/MockServer/avatars/originals/popeye.png
rename to PlexBar/Resources/MockServer/avatars/originals/popeye.png
diff --git a/Sources/PlexBar/Resources/MockServer/avatars/originals/scrump-toggins-og.png b/PlexBar/Resources/MockServer/avatars/originals/scrump-toggins-og.png
similarity index 100%
rename from Sources/PlexBar/Resources/MockServer/avatars/originals/scrump-toggins-og.png
rename to PlexBar/Resources/MockServer/avatars/originals/scrump-toggins-og.png
diff --git a/Sources/PlexBar/Resources/MockServer/avatars/originals/scrump-toggins.png b/PlexBar/Resources/MockServer/avatars/originals/scrump-toggins.png
similarity index 100%
rename from Sources/PlexBar/Resources/MockServer/avatars/originals/scrump-toggins.png
rename to PlexBar/Resources/MockServer/avatars/originals/scrump-toggins.png
diff --git a/Sources/PlexBar/Resources/MockServer/avatars/originals/tommy-shelby.png b/PlexBar/Resources/MockServer/avatars/originals/tommy-shelby.png
similarity index 100%
rename from Sources/PlexBar/Resources/MockServer/avatars/originals/tommy-shelby.png
rename to PlexBar/Resources/MockServer/avatars/originals/tommy-shelby.png
diff --git a/Sources/PlexBar/Resources/MockServer/avatars/popeye.png b/PlexBar/Resources/MockServer/avatars/popeye.png
similarity index 100%
rename from Sources/PlexBar/Resources/MockServer/avatars/popeye.png
rename to PlexBar/Resources/MockServer/avatars/popeye.png
diff --git a/Sources/PlexBar/Resources/MockServer/avatars/scrump-toggins-og.png b/PlexBar/Resources/MockServer/avatars/scrump-toggins-og.png
similarity index 100%
rename from Sources/PlexBar/Resources/MockServer/avatars/scrump-toggins-og.png
rename to PlexBar/Resources/MockServer/avatars/scrump-toggins-og.png
diff --git a/Sources/PlexBar/Resources/MockServer/avatars/scrump-toggins.png b/PlexBar/Resources/MockServer/avatars/scrump-toggins.png
similarity index 100%
rename from Sources/PlexBar/Resources/MockServer/avatars/scrump-toggins.png
rename to PlexBar/Resources/MockServer/avatars/scrump-toggins.png
diff --git a/PlexBar/Resources/MockServer/avatars/thebaumer.png b/PlexBar/Resources/MockServer/avatars/thebaumer.png
new file mode 100644
index 0000000..e971ad2
Binary files /dev/null and b/PlexBar/Resources/MockServer/avatars/thebaumer.png differ
diff --git a/Sources/PlexBar/Resources/MockServer/avatars/tommy-shelby.png b/PlexBar/Resources/MockServer/avatars/tommy-shelby.png
similarity index 100%
rename from Sources/PlexBar/Resources/MockServer/avatars/tommy-shelby.png
rename to PlexBar/Resources/MockServer/avatars/tommy-shelby.png
diff --git a/PlexBar/Resources/MockServer/media-catalog.json b/PlexBar/Resources/MockServer/media-catalog.json
new file mode 100644
index 0000000..da7566f
--- /dev/null
+++ b/PlexBar/Resources/MockServer/media-catalog.json
@@ -0,0 +1,20482 @@
+[
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+ "1191"
+ ],
+ "metadata" : {
+ "Country" : [
+ {
+ "tag" : "United States"
+ }
+ ],
+ "Director" : [
+ {
+ "id" : 50001,
+ "order" : 0,
+ "tag" : "Stanley Donen",
+ "tagKey" : "50001"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Mystery"
+ },
+ {
+ "tag" : "Comedy"
+ },
+ {
+ "tag" : "Romance"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0056923"
+ }
+ ],
+ "Producer" : [
+ {
+ "id" : 50001,
+ "order" : 0,
+ "tag" : "Stanley Donen",
+ "tagKey" : "50001"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "rottentomatoes://image.rating.ripe",
+ "type" : "critic",
+ "value" : 9.5
+ },
+ {
+ "image" : "rottentomatoes://image.rating.upright",
+ "type" : "audience",
+ "value" : 9.2
+ },
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7.8
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50004,
+ "order" : 0,
+ "role" : "Peter Joshua",
+ "tag" : "Cary Grant",
+ "tagKey" : "50004"
+ },
+ {
+ "id" : 50005,
+ "order" : 1,
+ "role" : "Regina Lampert",
+ "tag" : "Audrey Hepburn",
+ "tagKey" : "50005"
+ },
+ {
+ "id" : 50006,
+ "order" : 2,
+ "role" : "Hamilton Bartholomew",
+ "tag" : "Walter Matthau",
+ "tagKey" : "50006"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50002,
+ "order" : 0,
+ "tag" : "Peter Stone",
+ "tagKey" : "50002"
+ },
+ {
+ "id" : 50003,
+ "order" : 1,
+ "tag" : "Marc Behm",
+ "tagKey" : "50003"
+ }
+ ],
+ "art" : "/mock/art/movies/charade/backdrop.jpg",
+ "audienceRating" : 9.2,
+ "audienceRatingImage" : "rottentomatoes://image.rating.upright",
+ "duration" : 6780000,
+ "key" : "/library/metadata/1101",
+ "librarySectionID" : "library-movies",
+ "librarySectionTitle" : "Movies",
+ "originallyAvailableAt" : "1963-12-05",
+ "rating" : 9.5,
+ "ratingImage" : "rottentomatoes://image.rating.ripe",
+ "ratingKey" : "1101",
+ "studio" : "Stanley Donen Productions",
+ "summary" : "A newly widowed interpreter in Paris is pursued over missing wartime money. A charming stranger offers help, but his shifting identity makes him difficult to trust.",
+ "thumb" : "/mock/art/movies/charade/poster.png",
+ "title" : "Charade",
+ "titleSort" : "Charade",
+ "type" : "movie",
+ "userRating" : 8,
+ "viewCount" : 0,
+ "viewOffset" : 1800000,
+ "year" : 1963
+ },
+ "relatedIDs" : [
+ "1102",
+ "1103"
+ ],
+ "sources" : [
+ "https://catalog.afi.com/Film/23175-CHARADE",
+ "https://www.rottentomatoes.com/m/1003883-charade",
+ "https://watch.plex.tv/movie/charade",
+ "https://www.imdb.com/title/tt0056923/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 25200,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Country" : [
+ {
+ "tag" : "United States"
+ }
+ ],
+ "Director" : [
+ {
+ "id" : 50007,
+ "order" : 0,
+ "tag" : "George A. Romero",
+ "tagKey" : "50007"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Horror"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0063350"
+ }
+ ],
+ "Producer" : [
+ {
+ "id" : 50009,
+ "order" : 0,
+ "tag" : "Karl Hardman",
+ "tagKey" : "50009"
+ },
+ {
+ "id" : 50010,
+ "order" : 1,
+ "tag" : "Russell W. Streiner",
+ "tagKey" : "50010"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "rottentomatoes://image.rating.ripe",
+ "type" : "critic",
+ "value" : 9.5
+ },
+ {
+ "image" : "rottentomatoes://image.rating.upright",
+ "type" : "audience",
+ "value" : 8.7
+ },
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7.8
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50011,
+ "order" : 0,
+ "role" : "Ben",
+ "tag" : "Duane Jones",
+ "tagKey" : "50011"
+ },
+ {
+ "id" : 50012,
+ "order" : 1,
+ "role" : "Barbra",
+ "tag" : "Judith O’Dea",
+ "tagKey" : "50012"
+ },
+ {
+ "id" : 50009,
+ "order" : 2,
+ "role" : "Harry Cooper",
+ "tag" : "Karl Hardman",
+ "tagKey" : "50009"
+ },
+ {
+ "id" : 50013,
+ "order" : 3,
+ "role" : "Helen Cooper",
+ "tag" : "Marilyn Eastman",
+ "tagKey" : "50013"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50007,
+ "order" : 0,
+ "tag" : "George A. Romero",
+ "tagKey" : "50007"
+ },
+ {
+ "id" : 50008,
+ "order" : 1,
+ "tag" : "John A. Russo",
+ "tagKey" : "50008"
+ }
+ ],
+ "art" : "/mock/art/movies/night-of-the-living-dead/backdrop.jpg",
+ "audienceRating" : 8.7,
+ "audienceRatingImage" : "rottentomatoes://image.rating.upright",
+ "duration" : 5760000,
+ "key" : "/library/metadata/1102",
+ "librarySectionID" : "library-movies",
+ "librarySectionTitle" : "Movies",
+ "originallyAvailableAt" : "1968-10-01",
+ "rating" : 9.5,
+ "ratingImage" : "rottentomatoes://image.rating.ripe",
+ "ratingKey" : "1102",
+ "studio" : "Image Ten",
+ "summary" : "Strangers shelter in a Pennsylvania farmhouse as the dead attack outside. Fear and conflict inside the house threaten their efforts to survive.",
+ "thumb" : "/mock/art/movies/night-of-the-living-dead/poster.png",
+ "title" : "Night of the Living Dead",
+ "titleSort" : "Night of the Living Dead",
+ "type" : "movie",
+ "viewCount" : 0,
+ "year" : 1968
+ },
+ "relatedIDs" : [
+ "1101",
+ "1103"
+ ],
+ "sources" : [
+ "https://www.criterion.com/films/29331-night-of-the-living-dead",
+ "https://www.rottentomatoes.com/m/night_of_the_living_dead",
+ "https://watch.plex.tv/movie/night-of-the-living-dead",
+ "https://www.imdb.com/title/tt0063350/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 43200,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Country" : [
+ {
+ "tag" : "United States"
+ }
+ ],
+ "Director" : [
+ {
+ "id" : 50014,
+ "order" : 0,
+ "tag" : "Buster Keaton",
+ "tagKey" : "50014"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Comedy"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0015324"
+ }
+ ],
+ "Producer" : [
+ {
+ "id" : 50018,
+ "order" : 0,
+ "tag" : "Joseph M. Schenck",
+ "tagKey" : "50018"
+ },
+ {
+ "id" : 50014,
+ "order" : 1,
+ "tag" : "Buster Keaton",
+ "tagKey" : "50014"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "rottentomatoes://image.rating.ripe",
+ "type" : "critic",
+ "value" : 8.7
+ },
+ {
+ "image" : "rottentomatoes://image.rating.upright",
+ "type" : "audience",
+ "value" : 9.5
+ },
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 8.1
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50014,
+ "order" : 0,
+ "role" : "Projectionist / Sherlock Jr.",
+ "tag" : "Buster Keaton",
+ "tagKey" : "50014"
+ },
+ {
+ "id" : 50019,
+ "order" : 1,
+ "role" : "The Girl",
+ "tag" : "Kathryn McGuire",
+ "tagKey" : "50019"
+ },
+ {
+ "id" : 50020,
+ "order" : 2,
+ "role" : "The Sheik",
+ "tag" : "Ward Crane",
+ "tagKey" : "50020"
+ },
+ {
+ "id" : 50021,
+ "order" : 3,
+ "role" : "The Girl’s Father",
+ "tag" : "Joe Keaton",
+ "tagKey" : "50021"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50015,
+ "order" : 0,
+ "tag" : "Clyde Bruckman",
+ "tagKey" : "50015"
+ },
+ {
+ "id" : 50016,
+ "order" : 1,
+ "tag" : "Jean Havez",
+ "tagKey" : "50016"
+ },
+ {
+ "id" : 50017,
+ "order" : 2,
+ "tag" : "Joseph A. Mitchell",
+ "tagKey" : "50017"
+ }
+ ],
+ "art" : "/mock/art/movies/sherlock-jr/backdrop.jpg",
+ "audienceRating" : 9.5,
+ "audienceRatingImage" : "rottentomatoes://image.rating.upright",
+ "duration" : 2700000,
+ "key" : "/library/metadata/1103",
+ "librarySectionID" : "library-movies",
+ "librarySectionTitle" : "Movies",
+ "originallyAvailableAt" : "1924-04-21",
+ "rating" : 8.7,
+ "ratingImage" : "rottentomatoes://image.rating.ripe",
+ "ratingKey" : "1103",
+ "studio" : "Buster Keaton Productions",
+ "summary" : "A projectionist accused of theft dreams himself into a detective film, where impossible escapes and elaborate stunts turn him into the hero he hopes to be.",
+ "thumb" : "/mock/art/movies/sherlock-jr/poster.png",
+ "title" : "Sherlock Jr.",
+ "titleSort" : "Sherlock Jr.",
+ "type" : "movie",
+ "userRating" : 9,
+ "viewCount" : 1,
+ "year" : 1924
+ },
+ "relatedIDs" : [
+ "1101",
+ "1102"
+ ],
+ "sources" : [
+ "https://www.bfi.org.uk/film/4f7add12-c2af-5c5c-bb49-1647ce3240df/sherlock-jr",
+ "https://www.rottentomatoes.com/m/sherlock-jr",
+ "https://watch.plex.tv/movie/sherlock-jr",
+ "https://www.imdb.com/title/tt0015324/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 172800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Country" : [
+ {
+ "tag" : "Germany"
+ }
+ ],
+ "Director" : [
+ {
+ "id" : 50039,
+ "order" : 0,
+ "tag" : "F. W. Murnau",
+ "tagKey" : "50039"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Horror"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0013442"
+ }
+ ],
+ "Producer" : [
+ {
+ "id" : 50042,
+ "order" : 0,
+ "tag" : "Enrico Dieckmann",
+ "tagKey" : "50042"
+ },
+ {
+ "id" : 50043,
+ "order" : 1,
+ "tag" : "Albin Grau",
+ "tagKey" : "50043"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "rottentomatoes://image.rating.ripe",
+ "type" : "critic",
+ "value" : 9.7
+ },
+ {
+ "image" : "rottentomatoes://image.rating.upright",
+ "type" : "audience",
+ "value" : 8.7
+ },
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7.8
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50044,
+ "order" : 0,
+ "role" : "Count Orlok",
+ "tag" : "Max Schreck",
+ "tagKey" : "50044"
+ },
+ {
+ "id" : 50045,
+ "order" : 1,
+ "role" : "Thomas Hutter",
+ "tag" : "Gustav von Wangenheim",
+ "tagKey" : "50045"
+ },
+ {
+ "id" : 50046,
+ "order" : 2,
+ "role" : "Ellen Hutter",
+ "tag" : "Greta Schröder",
+ "tagKey" : "50046"
+ },
+ {
+ "id" : 50047,
+ "order" : 3,
+ "role" : "Knock",
+ "tag" : "Alexander Granach",
+ "tagKey" : "50047"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50040,
+ "order" : 0,
+ "tag" : "Henrik Galeen",
+ "tagKey" : "50040"
+ },
+ {
+ "id" : 50041,
+ "order" : 1,
+ "tag" : "Bram Stoker",
+ "tagKey" : "50041"
+ }
+ ],
+ "art" : "/mock/art/movies/nosferatu/backdrop.jpg",
+ "audienceRating" : 8.7,
+ "audienceRatingImage" : "rottentomatoes://image.rating.upright",
+ "duration" : 5640000,
+ "key" : "/library/metadata/1104",
+ "librarySectionID" : "library-movies",
+ "librarySectionTitle" : "Movies",
+ "originallyAvailableAt" : "1922-03-04",
+ "rating" : 9.7,
+ "ratingImage" : "rottentomatoes://image.rating.ripe",
+ "ratingKey" : "1104",
+ "studio" : "Prana Film",
+ "summary" : "An estate agent travels to Count Orlok’s remote castle, unaware that his client is a vampire. Orlok follows him to Wisborg, bringing plague and threatening his wife, Ellen.",
+ "thumb" : "/mock/art/movies/nosferatu/poster.png",
+ "title" : "Nosferatu",
+ "titleSort" : "Nosferatu",
+ "type" : "movie",
+ "viewCount" : 0,
+ "year" : 1922
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.murnau-stiftung.de/movie/674",
+ "https://bamlive.s3.amazonaws.com/BAMPFA_Program_Guide_Winter_2021-22.pdf",
+ "https://www.rottentomatoes.com/m/nosferatu",
+ "https://www.imdb.com/title/tt0013442/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 259200,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Country" : [
+ {
+ "tag" : "Germany"
+ }
+ ],
+ "Director" : [
+ {
+ "id" : 50048,
+ "order" : 0,
+ "tag" : "Fritz Lang",
+ "tagKey" : "50048"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ },
+ {
+ "tag" : "Drama"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0017136"
+ }
+ ],
+ "Producer" : [
+ {
+ "id" : 50050,
+ "order" : 0,
+ "tag" : "Erich Pommer",
+ "tagKey" : "50050"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "rottentomatoes://image.rating.ripe",
+ "type" : "critic",
+ "value" : 9.7
+ },
+ {
+ "image" : "rottentomatoes://image.rating.upright",
+ "type" : "audience",
+ "value" : 9.1
+ },
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 8.2
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50051,
+ "order" : 0,
+ "role" : "Maria / The Robot",
+ "tag" : "Brigitte Helm",
+ "tagKey" : "50051"
+ },
+ {
+ "id" : 50052,
+ "order" : 1,
+ "role" : "Joh Fredersen",
+ "tag" : "Alfred Abel",
+ "tagKey" : "50052"
+ },
+ {
+ "id" : 50053,
+ "order" : 2,
+ "role" : "Freder Fredersen",
+ "tag" : "Gustav Fröhlich",
+ "tagKey" : "50053"
+ },
+ {
+ "id" : 50054,
+ "order" : 3,
+ "role" : "Rotwang",
+ "tag" : "Rudolf Klein-Rogge",
+ "tagKey" : "50054"
+ },
+ {
+ "id" : 50055,
+ "order" : 4,
+ "role" : "The Thin Man",
+ "tag" : "Fritz Rasp",
+ "tagKey" : "50055"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50049,
+ "order" : 0,
+ "tag" : "Thea von Harbou",
+ "tagKey" : "50049"
+ }
+ ],
+ "art" : "/mock/art/movies/metropolis/backdrop.jpg",
+ "audienceRating" : 9.1,
+ "audienceRatingImage" : "rottentomatoes://image.rating.upright",
+ "duration" : 9180000,
+ "key" : "/library/metadata/1105",
+ "librarySectionID" : "library-movies",
+ "librarySectionTitle" : "Movies",
+ "originallyAvailableAt" : "1927-01-10",
+ "rating" : 9.7,
+ "ratingImage" : "rottentomatoes://image.rating.ripe",
+ "ratingKey" : "1105",
+ "studio" : "Universum Film AG (Ufa)",
+ "summary" : "In a futuristic city divided between wealthy rulers and underground workers, the ruler’s son discovers the human cost of its machinery. A robot disguised as a young reformer drives the city toward revolt.",
+ "thumb" : "/mock/art/movies/metropolis/poster.png",
+ "title" : "Metropolis",
+ "titleSort" : "Metropolis",
+ "type" : "movie",
+ "viewCount" : 0,
+ "year" : 1927
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.murnau-stiftung.de/movie/106",
+ "https://www.rottentomatoes.com/m/metropolis",
+ "https://www.imdb.com/title/tt0017136/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 345600,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Country" : [
+ {
+ "tag" : "United States"
+ }
+ ],
+ "Director" : [
+ {
+ "id" : 50056,
+ "order" : 0,
+ "tag" : "Harry O. Hoyt",
+ "tagKey" : "50056"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Adventure"
+ },
+ {
+ "tag" : "Fantasy"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0016039"
+ }
+ ],
+ "Producer" : [
+ {
+ "id" : 50059,
+ "order" : 0,
+ "tag" : "Watterson R. Rothacker",
+ "tagKey" : "50059"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "rottentomatoes://image.rating.ripe",
+ "type" : "critic",
+ "value" : 10
+ },
+ {
+ "image" : "rottentomatoes://image.rating.upright",
+ "type" : "audience",
+ "value" : 6.9
+ },
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50060,
+ "order" : 0,
+ "role" : "Professor Challenger",
+ "tag" : "Wallace Beery",
+ "tagKey" : "50060"
+ },
+ {
+ "id" : 50061,
+ "order" : 1,
+ "role" : "Paula White",
+ "tag" : "Bessie Love",
+ "tagKey" : "50061"
+ },
+ {
+ "id" : 50062,
+ "order" : 2,
+ "role" : "Ed Malone",
+ "tag" : "Lloyd Hughes",
+ "tagKey" : "50062"
+ },
+ {
+ "id" : 50063,
+ "order" : 3,
+ "role" : "Sir John Roxton",
+ "tag" : "Lewis Stone",
+ "tagKey" : "50063"
+ },
+ {
+ "id" : 50064,
+ "order" : 4,
+ "role" : "Professor Summerlee",
+ "tag" : "Arthur Hoyt",
+ "tagKey" : "50064"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50057,
+ "order" : 0,
+ "tag" : "Marion Fairfax",
+ "tagKey" : "50057"
+ },
+ {
+ "id" : 50058,
+ "order" : 1,
+ "tag" : "Arthur Conan Doyle",
+ "tagKey" : "50058"
+ }
+ ],
+ "art" : "/mock/art/movies/the-lost-world/backdrop.jpg",
+ "audienceRating" : 6.9,
+ "audienceRatingImage" : "rottentomatoes://image.rating.upright",
+ "duration" : 6480000,
+ "key" : "/library/metadata/1106",
+ "librarySectionID" : "library-movies",
+ "librarySectionTitle" : "Movies",
+ "originallyAvailableAt" : "1925-02-02",
+ "rating" : 10,
+ "ratingImage" : "rottentomatoes://image.rating.ripe",
+ "ratingKey" : "1106",
+ "studio" : "First National Pictures",
+ "summary" : "Professor Challenger leads an expedition to a remote South American plateau where dinosaurs still survive. Bringing a living specimen back to London turns a scientific triumph into chaos.",
+ "thumb" : "/mock/art/movies/the-lost-world/poster.png",
+ "title" : "The Lost World",
+ "titleSort" : "Lost World",
+ "type" : "movie",
+ "viewCount" : 0,
+ "year" : 1925
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://catalog.afi.com/Film/10384-THE-LOSTWORLD",
+ "https://www.rottentomatoes.com/m/1043525-lost_world",
+ "https://www.imdb.com/title/tt0016039/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 432000,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Country" : [
+ {
+ "tag" : "United States"
+ }
+ ],
+ "Director" : [
+ {
+ "id" : 50014,
+ "order" : 0,
+ "tag" : "Buster Keaton",
+ "tagKey" : "50014"
+ },
+ {
+ "id" : 50015,
+ "order" : 1,
+ "tag" : "Clyde Bruckman",
+ "tagKey" : "50015"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Comedy"
+ },
+ {
+ "tag" : "Action"
+ },
+ {
+ "tag" : "Adventure"
+ },
+ {
+ "tag" : "War"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0017925"
+ }
+ ],
+ "Producer" : [
+ {
+ "id" : 50018,
+ "order" : 0,
+ "tag" : "Joseph M. Schenck",
+ "tagKey" : "50018"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "rottentomatoes://image.rating.ripe",
+ "type" : "critic",
+ "value" : 9.2
+ },
+ {
+ "image" : "rottentomatoes://image.rating.upright",
+ "type" : "audience",
+ "value" : 9.2
+ },
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 8.1
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50014,
+ "order" : 0,
+ "role" : "Johnnie Gray",
+ "tag" : "Buster Keaton",
+ "tagKey" : "50014"
+ },
+ {
+ "id" : 50067,
+ "order" : 1,
+ "role" : "Annabelle Lee",
+ "tag" : "Marion Mack",
+ "tagKey" : "50067"
+ },
+ {
+ "id" : 50068,
+ "order" : 2,
+ "role" : "Captain Anderson",
+ "tag" : "Glen Cavender",
+ "tagKey" : "50068"
+ },
+ {
+ "id" : 50069,
+ "order" : 3,
+ "role" : "General Thatcher",
+ "tag" : "Jim Farley",
+ "tagKey" : "50069"
+ },
+ {
+ "id" : 50070,
+ "order" : 4,
+ "role" : "A Southern General",
+ "tag" : "Frederick Vroom",
+ "tagKey" : "50070"
+ },
+ {
+ "id" : 50021,
+ "order" : 5,
+ "role" : "Union General",
+ "tag" : "Joe Keaton",
+ "tagKey" : "50021"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50014,
+ "order" : 0,
+ "tag" : "Buster Keaton",
+ "tagKey" : "50014"
+ },
+ {
+ "id" : 50015,
+ "order" : 1,
+ "tag" : "Clyde Bruckman",
+ "tagKey" : "50015"
+ },
+ {
+ "id" : 50065,
+ "order" : 2,
+ "tag" : "Al Boasberg",
+ "tagKey" : "50065"
+ },
+ {
+ "id" : 50066,
+ "order" : 3,
+ "tag" : "Charles Smith",
+ "tagKey" : "50066"
+ }
+ ],
+ "art" : "/mock/art/movies/the-general/backdrop.jpg",
+ "audienceRating" : 9.2,
+ "audienceRatingImage" : "rottentomatoes://image.rating.upright",
+ "duration" : 4620000,
+ "key" : "/library/metadata/1107",
+ "librarySectionID" : "library-movies",
+ "librarySectionTitle" : "Movies",
+ "originallyAvailableAt" : "1926-12-25",
+ "rating" : 9.2,
+ "ratingImage" : "rottentomatoes://image.rating.ripe",
+ "ratingKey" : "1107",
+ "studio" : "Buster Keaton Productions",
+ "summary" : "During the American Civil War, Union raiders steal a locomotive with the engineer’s fiancée aboard. Johnnie Gray pursues them through enemy territory, determined to recover both his engine and Annabelle.",
+ "thumb" : "/mock/art/movies/the-general/poster.png",
+ "title" : "The General",
+ "titleSort" : "General",
+ "type" : "movie",
+ "viewCount" : 0,
+ "year" : 1926
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://catalog.afi.com/Film/9303-THE-GENERAL",
+ "https://www.imdb.com/title/tt0017925/releaseinfo/",
+ "https://www.rottentomatoes.com/m/1008166-general",
+ "https://www.imdb.com/title/tt0017925/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 518400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Country" : [
+ {
+ "tag" : "United States"
+ }
+ ],
+ "Director" : [
+ {
+ "id" : 50071,
+ "order" : 0,
+ "tag" : "Rupert Julian",
+ "tagKey" : "50071"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Horror"
+ },
+ {
+ "tag" : "Music"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0016220"
+ }
+ ],
+ "Producer" : [
+ {
+ "id" : 50075,
+ "order" : 0,
+ "tag" : "Carl Laemmle",
+ "tagKey" : "50075"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "rottentomatoes://image.rating.ripe",
+ "type" : "critic",
+ "value" : 9
+ },
+ {
+ "image" : "rottentomatoes://image.rating.upright",
+ "type" : "audience",
+ "value" : 8.4
+ },
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7.5
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50076,
+ "order" : 0,
+ "role" : "Erik / The Phantom",
+ "tag" : "Lon Chaney",
+ "tagKey" : "50076"
+ },
+ {
+ "id" : 50077,
+ "order" : 1,
+ "role" : "Christine Daaé",
+ "tag" : "Mary Philbin",
+ "tagKey" : "50077"
+ },
+ {
+ "id" : 50078,
+ "order" : 2,
+ "role" : "Vicomte Raoul de Chagny",
+ "tag" : "Norman Kerry",
+ "tagKey" : "50078"
+ },
+ {
+ "id" : 50079,
+ "order" : 3,
+ "role" : "Ledoux",
+ "tag" : "Arthur Edmund Carewe",
+ "tagKey" : "50079"
+ },
+ {
+ "id" : 50080,
+ "order" : 4,
+ "role" : "Simon Buquet",
+ "tag" : "Gibson Gowland",
+ "tagKey" : "50080"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50072,
+ "order" : 0,
+ "tag" : "Gaston Leroux",
+ "tagKey" : "50072"
+ },
+ {
+ "id" : 50073,
+ "order" : 1,
+ "tag" : "Bernard McConville",
+ "tagKey" : "50073"
+ },
+ {
+ "id" : 50074,
+ "order" : 2,
+ "tag" : "Elliott J. Clawson",
+ "tagKey" : "50074"
+ }
+ ],
+ "art" : "/mock/art/movies/the-phantom-of-the-opera/backdrop.jpg",
+ "audienceRating" : 8.4,
+ "audienceRatingImage" : "rottentomatoes://image.rating.upright",
+ "duration" : 6420000,
+ "key" : "/library/metadata/1108",
+ "librarySectionID" : "library-movies",
+ "librarySectionTitle" : "Movies",
+ "originallyAvailableAt" : "1925-09-06",
+ "rating" : 9,
+ "ratingImage" : "rottentomatoes://image.rating.ripe",
+ "ratingKey" : "1108",
+ "studio" : "Universal Pictures",
+ "summary" : "A mysterious figure beneath the Paris Opera helps a young singer become a star. His devotion turns possessive, drawing Christine and her lover into the hidden passages below the theater.",
+ "thumb" : "/mock/art/movies/the-phantom-of-the-opera/poster.png",
+ "title" : "The Phantom of the Opera",
+ "titleSort" : "Phantom of the Opera",
+ "type" : "movie",
+ "viewCount" : 0,
+ "year" : 1925
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0016220/releaseinfo/",
+ "https://www.imdb.com/title/tt0016220/technical/",
+ "https://www.rottentomatoes.com/m/phantom_of_the_opera_1925",
+ "https://www.imdb.com/title/tt0016220/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 604800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Country" : [
+ {
+ "tag" : "United States"
+ }
+ ],
+ "Director" : [
+ {
+ "id" : 50152,
+ "order" : 0,
+ "tag" : "Roger Corman",
+ "tagKey" : "50152"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Comedy"
+ },
+ {
+ "tag" : "Horror"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0054033"
+ }
+ ],
+ "Producer" : [
+ {
+ "id" : 50152,
+ "order" : 0,
+ "tag" : "Roger Corman",
+ "tagKey" : "50152"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "rottentomatoes://image.rating.ripe",
+ "type" : "critic",
+ "value" : 8.8
+ },
+ {
+ "image" : "rottentomatoes://image.rating.spilled",
+ "type" : "audience",
+ "value" : 5.5
+ },
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 6.2
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50154,
+ "order" : 0,
+ "role" : "Seymour Krelboyne",
+ "tag" : "Jonathan Haze",
+ "tagKey" : "50154"
+ },
+ {
+ "id" : 50155,
+ "order" : 1,
+ "role" : "Audrey Fulquard",
+ "tag" : "Jackie Joseph",
+ "tagKey" : "50155"
+ },
+ {
+ "id" : 50156,
+ "order" : 2,
+ "role" : "Gravis Mushnick",
+ "tag" : "Mel Welles",
+ "tagKey" : "50156"
+ },
+ {
+ "id" : 50157,
+ "order" : 3,
+ "role" : "Wilbur Force",
+ "tag" : "Jack Nicholson",
+ "tagKey" : "50157"
+ },
+ {
+ "id" : 50158,
+ "order" : 4,
+ "role" : "Burson Fouch",
+ "tag" : "Dick Miller",
+ "tagKey" : "50158"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50153,
+ "order" : 0,
+ "tag" : "Charles B. Griffith",
+ "tagKey" : "50153"
+ }
+ ],
+ "art" : "/mock/art/movies/the-little-shop-of-horrors/backdrop.jpg",
+ "audienceRating" : 5.5,
+ "audienceRatingImage" : "rottentomatoes://image.rating.spilled",
+ "duration" : 4200000,
+ "key" : "/library/metadata/1109",
+ "librarySectionID" : "library-movies",
+ "librarySectionTitle" : "Movies",
+ "originallyAvailableAt" : "1960-09-14",
+ "rating" : 8.8,
+ "ratingImage" : "rottentomatoes://image.rating.ripe",
+ "ratingKey" : "1109",
+ "studio" : "Santa Clara Productions",
+ "summary" : "A struggling florist’s assistant brings an unusual plant into the shop to save his job. Business flourishes, but the plant’s appetite for human blood draws him into a series of increasingly grisly predicaments.",
+ "thumb" : "/mock/art/movies/the-little-shop-of-horrors/poster.png",
+ "title" : "The Little Shop of Horrors",
+ "titleSort" : "Little Shop of Horrors",
+ "type" : "movie",
+ "viewCount" : 0,
+ "year" : 1960
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://catalog.afi.com/Catalog/moviedetails/53212",
+ "https://www.rottentomatoes.com/m/1012514-little_shop_of_horrors",
+ "https://www.imdb.com/title/tt0054033/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 691200,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Country" : [
+ {
+ "tag" : "United States"
+ }
+ ],
+ "Director" : [
+ {
+ "id" : 50159,
+ "order" : 0,
+ "tag" : "William A. Wellman",
+ "tagKey" : "50159"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Drama"
+ },
+ {
+ "tag" : "Romance"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0029606"
+ }
+ ],
+ "Producer" : [
+ {
+ "id" : 50162,
+ "order" : 0,
+ "tag" : "David O. Selznick",
+ "tagKey" : "50162"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "rottentomatoes://image.rating.ripe",
+ "type" : "critic",
+ "value" : 10
+ },
+ {
+ "image" : "rottentomatoes://image.rating.upright",
+ "type" : "audience",
+ "value" : 7.8
+ },
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7.3
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50163,
+ "order" : 0,
+ "role" : "Esther Blodgett / Vicki Lester",
+ "tag" : "Janet Gaynor",
+ "tagKey" : "50163"
+ },
+ {
+ "id" : 50164,
+ "order" : 1,
+ "role" : "Norman Maine",
+ "tag" : "Fredric March",
+ "tagKey" : "50164"
+ },
+ {
+ "id" : 50165,
+ "order" : 2,
+ "role" : "Oliver Niles",
+ "tag" : "Adolphe Menjou",
+ "tagKey" : "50165"
+ },
+ {
+ "id" : 50166,
+ "order" : 3,
+ "role" : "Grandmother Lettie Blodgett",
+ "tag" : "May Robson",
+ "tagKey" : "50166"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50160,
+ "order" : 0,
+ "tag" : "Dorothy Parker",
+ "tagKey" : "50160"
+ },
+ {
+ "id" : 50161,
+ "order" : 1,
+ "tag" : "Alan Campbell",
+ "tagKey" : "50161"
+ },
+ {
+ "id" : 50148,
+ "order" : 2,
+ "tag" : "Robert Carson",
+ "tagKey" : "50148"
+ },
+ {
+ "id" : 50159,
+ "order" : 3,
+ "tag" : "William A. Wellman",
+ "tagKey" : "50159"
+ }
+ ],
+ "art" : "/mock/art/movies/a-star-is-born/backdrop.jpg",
+ "audienceRating" : 7.8,
+ "audienceRatingImage" : "rottentomatoes://image.rating.upright",
+ "duration" : 6660000,
+ "key" : "/library/metadata/1110",
+ "librarySectionID" : "library-movies",
+ "librarySectionTitle" : "Movies",
+ "originallyAvailableAt" : "1937-04-30",
+ "rating" : 10,
+ "ratingImage" : "rottentomatoes://image.rating.ripe",
+ "ratingKey" : "1110",
+ "studio" : "Selznick International Pictures",
+ "summary" : "An aspiring actress finds a mentor and husband in a celebrated Hollywood actor. As her career takes off, his fading fame and alcoholism threaten the life they have built together.",
+ "thumb" : "/mock/art/movies/a-star-is-born/poster.png",
+ "title" : "A Star Is Born",
+ "titleSort" : "Star Is Born",
+ "type" : "movie",
+ "viewCount" : 0,
+ "year" : 1937
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://catalog.afi.com/Film/5000-A-STAR-IS-BORN",
+ "https://www.rottentomatoes.com/m/1019828-star_is_born",
+ "https://www.imdb.com/title/tt0029606/ratings/",
+ "https://www.imdb.com/title/tt0029606/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 777600,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Country" : [
+ {
+ "tag" : "United States"
+ }
+ ],
+ "Director" : [
+ {
+ "id" : 50167,
+ "order" : 0,
+ "tag" : "Gregory La Cava",
+ "tagKey" : "50167"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Comedy"
+ },
+ {
+ "tag" : "Drama"
+ },
+ {
+ "tag" : "Romance"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0028010"
+ }
+ ],
+ "Producer" : [
+ {
+ "id" : 50170,
+ "order" : 0,
+ "tag" : "Charles R. Rogers",
+ "tagKey" : "50170"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "rottentomatoes://image.rating.ripe",
+ "type" : "critic",
+ "value" : 9.7
+ },
+ {
+ "image" : "rottentomatoes://image.rating.upright",
+ "type" : "audience",
+ "value" : 9
+ },
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7.9
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50171,
+ "order" : 0,
+ "role" : "Godfrey Parke",
+ "tag" : "William Powell",
+ "tagKey" : "50171"
+ },
+ {
+ "id" : 50172,
+ "order" : 1,
+ "role" : "Irene Bullock",
+ "tag" : "Carole Lombard",
+ "tagKey" : "50172"
+ },
+ {
+ "id" : 50173,
+ "order" : 2,
+ "role" : "Angelica Bullock",
+ "tag" : "Alice Brady",
+ "tagKey" : "50173"
+ },
+ {
+ "id" : 50174,
+ "order" : 3,
+ "role" : "Cornelia Bullock",
+ "tag" : "Gail Patrick",
+ "tagKey" : "50174"
+ },
+ {
+ "id" : 50175,
+ "order" : 4,
+ "role" : "Molly",
+ "tag" : "Jean Dixon",
+ "tagKey" : "50175"
+ },
+ {
+ "id" : 50176,
+ "order" : 5,
+ "role" : "Alexander Bullock",
+ "tag" : "Eugene Pallette",
+ "tagKey" : "50176"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50168,
+ "order" : 0,
+ "tag" : "Morrie Ryskind",
+ "tagKey" : "50168"
+ },
+ {
+ "id" : 50169,
+ "order" : 1,
+ "tag" : "Eric Hatch",
+ "tagKey" : "50169"
+ }
+ ],
+ "art" : "/mock/art/movies/my-man-godfrey/backdrop.jpg",
+ "audienceRating" : 9,
+ "audienceRatingImage" : "rottentomatoes://image.rating.upright",
+ "duration" : 5640000,
+ "key" : "/library/metadata/1111",
+ "librarySectionID" : "library-movies",
+ "librarySectionTitle" : "Movies",
+ "originallyAvailableAt" : "1936-09-06",
+ "rating" : 9.7,
+ "ratingImage" : "rottentomatoes://image.rating.ripe",
+ "ratingKey" : "1111",
+ "studio" : "Universal Pictures",
+ "summary" : "A wealthy young woman recruits a homeless man for a scavenger hunt, then hires him as her family’s butler. His composure unsettles the eccentric household, while his past proves more complicated than anyone expects.",
+ "thumb" : "/mock/art/movies/my-man-godfrey/poster.png",
+ "title" : "My Man Godfrey",
+ "titleSort" : "My Man Godfrey",
+ "type" : "movie",
+ "viewCount" : 0,
+ "year" : 1936
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://catalog.afi.com/Film/5200-MY-MAN-GODFREY",
+ "https://www.rottentomatoes.com/m/1014536-my_man_godfrey",
+ "https://www.imdb.com/title/tt0028010/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 864000,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Country" : [
+ {
+ "tag" : "United States"
+ }
+ ],
+ "Director" : [
+ {
+ "id" : 50177,
+ "order" : 0,
+ "tag" : "Orson Welles",
+ "tagKey" : "50177"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Drama"
+ },
+ {
+ "tag" : "Mystery"
+ },
+ {
+ "tag" : "Thriller"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0038991"
+ }
+ ],
+ "Producer" : [
+ {
+ "id" : 50181,
+ "order" : 0,
+ "tag" : "Sam Spiegel",
+ "tagKey" : "50181"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "rottentomatoes://image.rating.ripe",
+ "type" : "critic",
+ "value" : 9.7
+ },
+ {
+ "image" : "rottentomatoes://image.rating.upright",
+ "type" : "audience",
+ "value" : 8.1
+ },
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7.3
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50182,
+ "order" : 0,
+ "role" : "Wilson",
+ "tag" : "Edward G. Robinson",
+ "tagKey" : "50182"
+ },
+ {
+ "id" : 50183,
+ "order" : 1,
+ "role" : "Mary Longstreet Rankin",
+ "tag" : "Loretta Young",
+ "tagKey" : "50183"
+ },
+ {
+ "id" : 50177,
+ "order" : 2,
+ "role" : "Franz Kindler / Professor Charles Rankin",
+ "tag" : "Orson Welles",
+ "tagKey" : "50177"
+ },
+ {
+ "id" : 50184,
+ "order" : 3,
+ "role" : "Judge Longstreet",
+ "tag" : "Philip Merivale",
+ "tagKey" : "50184"
+ },
+ {
+ "id" : 50185,
+ "order" : 4,
+ "role" : "Noah Longstreet",
+ "tag" : "Richard Long",
+ "tagKey" : "50185"
+ },
+ {
+ "id" : 50186,
+ "order" : 5,
+ "role" : "Meinike",
+ "tag" : "Konstantin Shayne",
+ "tagKey" : "50186"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50178,
+ "order" : 0,
+ "tag" : "Anthony Veiller",
+ "tagKey" : "50178"
+ },
+ {
+ "id" : 50179,
+ "order" : 1,
+ "tag" : "Victor Trivas",
+ "tagKey" : "50179"
+ },
+ {
+ "id" : 50180,
+ "order" : 2,
+ "tag" : "Decla Dunning",
+ "tagKey" : "50180"
+ }
+ ],
+ "art" : "/mock/art/movies/the-stranger/backdrop.jpg",
+ "audienceRating" : 8.1,
+ "audienceRatingImage" : "rottentomatoes://image.rating.upright",
+ "duration" : 5700000,
+ "key" : "/library/metadata/1112",
+ "librarySectionID" : "library-movies",
+ "librarySectionTitle" : "Movies",
+ "originallyAvailableAt" : "1946-05-25",
+ "rating" : 9.7,
+ "ratingImage" : "rottentomatoes://image.rating.ripe",
+ "ratingKey" : "1112",
+ "studio" : "International Pictures",
+ "summary" : "A war-crimes investigator follows a fugitive’s trail to a quiet New England town. A respected teacher’s carefully constructed identity begins to unravel as the investigator closes in on the Nazi hiding behind it.",
+ "thumb" : "/mock/art/movies/the-stranger/poster.png",
+ "title" : "The Stranger",
+ "titleSort" : "Stranger",
+ "type" : "movie",
+ "viewCount" : 0,
+ "year" : 1946
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://catalog.afi.com/Catalog/moviedetails/24989",
+ "https://www.rottentomatoes.com/m/1020287-stranger",
+ "https://watch.plex.tv/movie/the-stranger-1946",
+ "https://www.imdb.com/title/tt0038991/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 950400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Country" : [
+ {
+ "tag" : "United States"
+ }
+ ],
+ "Director" : [
+ {
+ "id" : 50187,
+ "order" : 0,
+ "tag" : "Edward D. Wood Jr.",
+ "tagKey" : "50187"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ },
+ {
+ "tag" : "Horror"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0052077"
+ }
+ ],
+ "Producer" : [
+ {
+ "id" : 50187,
+ "order" : 0,
+ "tag" : "Edward D. Wood Jr.",
+ "tagKey" : "50187"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "rottentomatoes://image.rating.ripe",
+ "type" : "critic",
+ "value" : 6.6
+ },
+ {
+ "image" : "rottentomatoes://image.rating.spilled",
+ "type" : "audience",
+ "value" : 4.5
+ },
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 3.9
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50188,
+ "order" : 0,
+ "role" : "Jeff Trent",
+ "tag" : "Gregory Walcott",
+ "tagKey" : "50188"
+ },
+ {
+ "id" : 50189,
+ "order" : 1,
+ "role" : "Paula Trent",
+ "tag" : "Mona McKinnon",
+ "tagKey" : "50189"
+ },
+ {
+ "id" : 50190,
+ "order" : 2,
+ "role" : "Lieutenant John Harper",
+ "tag" : "Duke Moore",
+ "tagKey" : "50190"
+ },
+ {
+ "id" : 50191,
+ "order" : 3,
+ "role" : "Inspector Clay",
+ "tag" : "Tor Johnson",
+ "tagKey" : "50191"
+ },
+ {
+ "id" : 50192,
+ "order" : 4,
+ "role" : "Vampire Girl",
+ "tag" : "Vampira",
+ "tagKey" : "50192"
+ },
+ {
+ "id" : 50193,
+ "order" : 5,
+ "role" : "Ghoul Man",
+ "tag" : "Bela Lugosi",
+ "tagKey" : "50193"
+ },
+ {
+ "id" : 50194,
+ "order" : 6,
+ "role" : "Eros",
+ "tag" : "Dudley Manlove",
+ "tagKey" : "50194"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50187,
+ "order" : 0,
+ "tag" : "Edward D. Wood Jr.",
+ "tagKey" : "50187"
+ }
+ ],
+ "art" : "/mock/art/movies/plan-9-from-outer-space/backdrop.jpg",
+ "audienceRating" : 4.5,
+ "audienceRatingImage" : "rottentomatoes://image.rating.spilled",
+ "duration" : 4680000,
+ "key" : "/library/metadata/1113",
+ "librarySectionID" : "library-movies",
+ "librarySectionTitle" : "Movies",
+ "originallyAvailableAt" : "1959-07-22",
+ "rating" : 6.6,
+ "ratingImage" : "rottentomatoes://image.rating.ripe",
+ "ratingKey" : "1113",
+ "studio" : "Reynolds Pictures",
+ "summary" : "Flying saucers appear over California as aliens put a plan to reanimate the dead into motion. A pilot and the local authorities investigate the cemetery where the visitors are turning corpses against the living.",
+ "thumb" : "/mock/art/movies/plan-9-from-outer-space/poster.png",
+ "title" : "Plan 9 from Outer Space",
+ "titleSort" : "Plan 9 from Outer Space",
+ "type" : "movie",
+ "viewCount" : 0,
+ "year" : 1959
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://catalog.afi.com/Film/52989-PLAN-9-FROM-OUTER-SPACE",
+ "https://www.rottentomatoes.com/m/plan-9-from-outer-space",
+ "https://watch.plex.tv/movie/plan-9-from-outer-space",
+ "https://www.imdb.com/title/tt0052077/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1036800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Country" : [
+ {
+ "tag" : "United States"
+ }
+ ],
+ "Director" : [
+ {
+ "id" : 50195,
+ "order" : 0,
+ "tag" : "Frank Capra",
+ "tagKey" : "50195"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Drama"
+ },
+ {
+ "tag" : "Fantasy"
+ },
+ {
+ "tag" : "Family"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0038650"
+ }
+ ],
+ "Producer" : [
+ {
+ "id" : 50195,
+ "order" : 0,
+ "tag" : "Frank Capra",
+ "tagKey" : "50195"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "rottentomatoes://image.rating.ripe",
+ "type" : "critic",
+ "value" : 9.4
+ },
+ {
+ "image" : "rottentomatoes://image.rating.upright",
+ "type" : "audience",
+ "value" : 9.5
+ },
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 8.6
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50199,
+ "order" : 0,
+ "role" : "George Bailey",
+ "tag" : "James Stewart",
+ "tagKey" : "50199"
+ },
+ {
+ "id" : 50200,
+ "order" : 1,
+ "role" : "Mary Hatch Bailey",
+ "tag" : "Donna Reed",
+ "tagKey" : "50200"
+ },
+ {
+ "id" : 50201,
+ "order" : 2,
+ "role" : "Henry F. Potter",
+ "tag" : "Lionel Barrymore",
+ "tagKey" : "50201"
+ },
+ {
+ "id" : 50202,
+ "order" : 3,
+ "role" : "Uncle Billy Bailey",
+ "tag" : "Thomas Mitchell",
+ "tagKey" : "50202"
+ },
+ {
+ "id" : 50203,
+ "order" : 4,
+ "role" : "Clarence Oddbody",
+ "tag" : "Henry Travers",
+ "tagKey" : "50203"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50196,
+ "order" : 0,
+ "tag" : "Frances Goodrich",
+ "tagKey" : "50196"
+ },
+ {
+ "id" : 50197,
+ "order" : 1,
+ "tag" : "Albert Hackett",
+ "tagKey" : "50197"
+ },
+ {
+ "id" : 50195,
+ "order" : 2,
+ "tag" : "Frank Capra",
+ "tagKey" : "50195"
+ },
+ {
+ "id" : 50198,
+ "order" : 3,
+ "tag" : "Philip Van Doren Stern",
+ "tagKey" : "50198"
+ }
+ ],
+ "art" : "/mock/art/movies/its-a-wonderful-life/backdrop.jpg",
+ "audienceRating" : 9.5,
+ "audienceRatingImage" : "rottentomatoes://image.rating.upright",
+ "duration" : 7740000,
+ "key" : "/library/metadata/1114",
+ "librarySectionID" : "library-movies",
+ "librarySectionTitle" : "Movies",
+ "originallyAvailableAt" : "1946-12-20",
+ "rating" : 9.4,
+ "ratingImage" : "rottentomatoes://image.rating.ripe",
+ "ratingKey" : "1114",
+ "studio" : "Liberty Films",
+ "summary" : "Facing financial ruin on Christmas Eve, George Bailey begins to believe his life has been a failure. An angel shows him how profoundly his family and hometown would have suffered without him.",
+ "thumb" : "/mock/art/movies/its-a-wonderful-life/poster.png",
+ "title" : "It's a Wonderful Life",
+ "titleSort" : "It's a Wonderful Life",
+ "type" : "movie",
+ "viewCount" : 0,
+ "year" : 1946
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://catalog.afi.com/Catalog/MovieDetails/27682",
+ "https://www.rottentomatoes.com/m/its_a_wonderful_life",
+ "https://www.imdb.com/title/tt0038650/ratings/",
+ "https://www.imdb.com/title/tt0038650/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1123200,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Country" : [
+ {
+ "tag" : "United States"
+ }
+ ],
+ "Director" : [
+ {
+ "id" : 50204,
+ "order" : 0,
+ "tag" : "Stanley Kubrick",
+ "tagKey" : "50204"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "War"
+ },
+ {
+ "tag" : "Drama"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0045758"
+ }
+ ],
+ "Producer" : [
+ {
+ "id" : 50204,
+ "order" : 0,
+ "tag" : "Stanley Kubrick",
+ "tagKey" : "50204"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "rottentomatoes://image.rating.ripe",
+ "type" : "critic",
+ "value" : 7
+ },
+ {
+ "image" : "rottentomatoes://image.rating.spilled",
+ "type" : "audience",
+ "value" : 3.4
+ },
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 5.3
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50206,
+ "order" : 0,
+ "role" : "Sergeant Mac",
+ "tag" : "Frank Silvera",
+ "tagKey" : "50206"
+ },
+ {
+ "id" : 50207,
+ "order" : 1,
+ "role" : "Lieutenant Corby / The General",
+ "tag" : "Kenneth Harp",
+ "tagKey" : "50207"
+ },
+ {
+ "id" : 50208,
+ "order" : 2,
+ "role" : "Sidney",
+ "tag" : "Paul Mazursky",
+ "tagKey" : "50208"
+ },
+ {
+ "id" : 50209,
+ "order" : 3,
+ "role" : "Fletcher / The Captain",
+ "tag" : "Stephen Coit",
+ "tagKey" : "50209"
+ },
+ {
+ "id" : 50034,
+ "order" : 4,
+ "role" : "The Girl",
+ "tag" : "Virginia Leith",
+ "tagKey" : "50034"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50205,
+ "order" : 0,
+ "tag" : "Howard Sackler",
+ "tagKey" : "50205"
+ }
+ ],
+ "art" : "/mock/art/movies/fear-and-desire/backdrop.jpg",
+ "audienceRating" : 3.4,
+ "audienceRatingImage" : "rottentomatoes://image.rating.spilled",
+ "duration" : 4080000,
+ "key" : "/library/metadata/1115",
+ "librarySectionID" : "library-movies",
+ "librarySectionTitle" : "Movies",
+ "originallyAvailableAt" : "1953-03-31",
+ "rating" : 7,
+ "ratingImage" : "rottentomatoes://image.rating.ripe",
+ "ratingKey" : "1115",
+ "studio" : "Kubrick Family",
+ "summary" : "Four soldiers stranded behind enemy lines try to escape through a forest during an unnamed war. Their encounter with a local woman and a nearby enemy outpost exposes the fear and violence within their own ranks.",
+ "thumb" : "/mock/art/movies/fear-and-desire/poster.png",
+ "title" : "Fear and Desire",
+ "titleSort" : "Fear and Desire",
+ "type" : "movie",
+ "viewCount" : 0,
+ "year" : 1953
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://catalog.afi.com/Film/50828-FEAR-AND-DESIRE",
+ "https://www.rottentomatoes.com/m/fear_and_desire",
+ "https://watch.plex.tv/movie/fear-and-desire",
+ "https://www.imdb.com/title/tt0045758/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1209600,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Country" : [
+ {
+ "tag" : "United States"
+ }
+ ],
+ "Director" : [
+ {
+ "id" : 50210,
+ "order" : 0,
+ "tag" : "Crane Wilbur",
+ "tagKey" : "50210"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Horror"
+ },
+ {
+ "tag" : "Mystery"
+ },
+ {
+ "tag" : "Thriller"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0052602"
+ }
+ ],
+ "Producer" : [
+ {
+ "id" : 50213,
+ "order" : 0,
+ "tag" : "C. J. Tevlin",
+ "tagKey" : "50213"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "rottentomatoes://image.rating.rotten",
+ "type" : "critic",
+ "value" : 1.7
+ },
+ {
+ "image" : "rottentomatoes://image.rating.spilled",
+ "type" : "audience",
+ "value" : 4.7
+ },
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 6.1
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50214,
+ "order" : 0,
+ "role" : "Dr. Malcolm Wells",
+ "tag" : "Vincent Price",
+ "tagKey" : "50214"
+ },
+ {
+ "id" : 50215,
+ "order" : 1,
+ "role" : "Cornelia Van Gorder",
+ "tag" : "Agnes Moorehead",
+ "tagKey" : "50215"
+ },
+ {
+ "id" : 50216,
+ "order" : 2,
+ "role" : "Lieutenant Andy Anderson",
+ "tag" : "Gavin Gordon",
+ "tagKey" : "50216"
+ },
+ {
+ "id" : 50217,
+ "order" : 3,
+ "role" : "Warner",
+ "tag" : "John Sutton",
+ "tagKey" : "50217"
+ },
+ {
+ "id" : 50218,
+ "order" : 4,
+ "role" : "Lizzie Allen",
+ "tag" : "Lenita Lane",
+ "tagKey" : "50218"
+ },
+ {
+ "id" : 50219,
+ "order" : 5,
+ "role" : "Dale Bailey",
+ "tag" : "Elaine Edwards",
+ "tagKey" : "50219"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50210,
+ "order" : 0,
+ "tag" : "Crane Wilbur",
+ "tagKey" : "50210"
+ },
+ {
+ "id" : 50211,
+ "order" : 1,
+ "tag" : "Mary Roberts Rinehart",
+ "tagKey" : "50211"
+ },
+ {
+ "id" : 50212,
+ "order" : 2,
+ "tag" : "Avery Hopwood",
+ "tagKey" : "50212"
+ }
+ ],
+ "art" : "/mock/art/movies/the-bat/backdrop.jpg",
+ "audienceRating" : 4.7,
+ "audienceRatingImage" : "rottentomatoes://image.rating.spilled",
+ "duration" : 4800000,
+ "key" : "/library/metadata/1116",
+ "librarySectionID" : "library-movies",
+ "librarySectionTitle" : "Movies",
+ "originallyAvailableAt" : "1959-08-09",
+ "rating" : 1.7,
+ "ratingImage" : "rottentomatoes://image.rating.rotten",
+ "ratingKey" : "1116",
+ "studio" : "Liberty Pictures",
+ "summary" : "A mystery novelist rents a country mansion where a stolen fortune has been hidden. As a masked killer stalks the house, she and her companions must unravel the mystery before becoming his next victims.",
+ "thumb" : "/mock/art/movies/the-bat/poster.png",
+ "title" : "The Bat",
+ "titleSort" : "Bat",
+ "type" : "movie",
+ "viewCount" : 0,
+ "year" : 1959
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://catalog.afi.com/Film/52821-THE-BAT",
+ "https://www.rottentomatoes.com/m/1045841-bat",
+ "https://watch.plex.tv/movie/the-bat-1959",
+ "https://www.imdb.com/title/tt0052602/fullcredits/",
+ "https://www.imdb.com/title/tt0052602/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1296000,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Country" : [
+ {
+ "tag" : "United States"
+ },
+ {
+ "tag" : "Italy"
+ }
+ ],
+ "Director" : [
+ {
+ "id" : 50220,
+ "order" : 0,
+ "tag" : "Sidney Salkow",
+ "tagKey" : "50220"
+ },
+ {
+ "id" : 50221,
+ "order" : 1,
+ "tag" : "Ubaldo Ragona",
+ "tagKey" : "50221"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ },
+ {
+ "tag" : "Horror"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0058700"
+ }
+ ],
+ "Producer" : [
+ {
+ "id" : 50225,
+ "order" : 0,
+ "tag" : "Robert L. Lippert",
+ "tagKey" : "50225"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "rottentomatoes://image.rating.ripe",
+ "type" : "critic",
+ "value" : 7.9
+ },
+ {
+ "image" : "rottentomatoes://image.rating.upright",
+ "type" : "audience",
+ "value" : 6.8
+ },
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 6.7
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50214,
+ "order" : 0,
+ "role" : "Dr. Robert Morgan",
+ "tag" : "Vincent Price",
+ "tagKey" : "50214"
+ },
+ {
+ "id" : 50226,
+ "order" : 1,
+ "role" : "Ruth",
+ "tag" : "Franca Bettoia",
+ "tagKey" : "50226"
+ },
+ {
+ "id" : 50227,
+ "order" : 2,
+ "role" : "Virginia Morgan",
+ "tag" : "Emma Danieli",
+ "tagKey" : "50227"
+ },
+ {
+ "id" : 50228,
+ "order" : 3,
+ "role" : "Ben Cortman",
+ "tag" : "Giacomo Rossi Stuart",
+ "tagKey" : "50228"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50222,
+ "order" : 0,
+ "tag" : "Richard Matheson",
+ "tagKey" : "50222"
+ },
+ {
+ "id" : 50223,
+ "order" : 1,
+ "tag" : "William F. Leicester",
+ "tagKey" : "50223"
+ },
+ {
+ "id" : 50221,
+ "order" : 2,
+ "tag" : "Ubaldo Ragona",
+ "tagKey" : "50221"
+ },
+ {
+ "id" : 50224,
+ "order" : 3,
+ "tag" : "Furio M. Monetti",
+ "tagKey" : "50224"
+ }
+ ],
+ "art" : "/mock/art/movies/the-last-man-on-earth/backdrop.jpg",
+ "audienceRating" : 6.8,
+ "audienceRatingImage" : "rottentomatoes://image.rating.upright",
+ "duration" : 5160000,
+ "key" : "/library/metadata/1117",
+ "librarySectionID" : "library-movies",
+ "librarySectionTitle" : "Movies",
+ "originallyAvailableAt" : "1964-03-08",
+ "rating" : 7.9,
+ "ratingImage" : "rottentomatoes://image.rating.ripe",
+ "ratingKey" : "1117",
+ "studio" : "Associated Producers",
+ "summary" : "After a plague transforms people into nocturnal creatures, an immune scientist spends his days hunting them and his nights barricaded at home. An encounter with another survivor challenges his belief that he understands what remains of humanity.",
+ "thumb" : "/mock/art/movies/the-last-man-on-earth/poster.png",
+ "title" : "The Last Man on Earth",
+ "titleSort" : "Last Man on Earth",
+ "type" : "movie",
+ "viewCount" : 0,
+ "year" : 1964
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://catalog.afi.com/Film/23303-THE-LAST-MAN-ON-EARTH",
+ "https://www.rottentomatoes.com/m/1050388-last_man_on_earth",
+ "https://watch.plex.tv/movie/the-last-man-on-earth",
+ "https://www.imdb.com/title/tt0058700/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1382400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Country" : [
+ {
+ "tag" : "United States"
+ }
+ ],
+ "Director" : [
+ {
+ "id" : 50229,
+ "order" : 0,
+ "tag" : "Louis J. Gasnier",
+ "tagKey" : "50229"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Drama"
+ },
+ {
+ "tag" : "Crime"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0028346"
+ }
+ ],
+ "Producer" : [
+ {
+ "id" : 50233,
+ "order" : 0,
+ "tag" : "George A. Hirliman",
+ "tagKey" : "50233"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "rottentomatoes://image.rating.rotten",
+ "type" : "critic",
+ "value" : 3.9
+ },
+ {
+ "image" : "rottentomatoes://image.rating.spilled",
+ "type" : "audience",
+ "value" : 3.7
+ },
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 3.8
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50234,
+ "order" : 0,
+ "role" : "Mary Lane",
+ "tag" : "Dorothy Short",
+ "tagKey" : "50234"
+ },
+ {
+ "id" : 50235,
+ "order" : 1,
+ "role" : "Bill Harper",
+ "tag" : "Kenneth Craig",
+ "tagKey" : "50235"
+ },
+ {
+ "id" : 50236,
+ "order" : 2,
+ "role" : "Blanche",
+ "tag" : "Lillian Miles",
+ "tagKey" : "50236"
+ },
+ {
+ "id" : 50237,
+ "order" : 3,
+ "role" : "Ralph Wiley",
+ "tag" : "Dave O’Brien",
+ "tagKey" : "50237"
+ },
+ {
+ "id" : 50238,
+ "order" : 4,
+ "role" : "Mae Colman",
+ "tag" : "Thelma White",
+ "tagKey" : "50238"
+ },
+ {
+ "id" : 50239,
+ "order" : 5,
+ "role" : "Jack Perry",
+ "tag" : "Carleton Young",
+ "tagKey" : "50239"
+ },
+ {
+ "id" : 50240,
+ "order" : 6,
+ "role" : "Dr. Alfred Carroll",
+ "tag" : "Joseph Forte",
+ "tagKey" : "50240"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50230,
+ "order" : 0,
+ "tag" : "Arthur Hoerl",
+ "tagKey" : "50230"
+ },
+ {
+ "id" : 50231,
+ "order" : 1,
+ "tag" : "Lawrence Meade",
+ "tagKey" : "50231"
+ },
+ {
+ "id" : 50232,
+ "order" : 2,
+ "tag" : "Paul Franklin",
+ "tagKey" : "50232"
+ }
+ ],
+ "art" : "/mock/art/movies/reefer-madness/backdrop.jpg",
+ "audienceRating" : 3.7,
+ "audienceRatingImage" : "rottentomatoes://image.rating.spilled",
+ "duration" : 3900000,
+ "key" : "/library/metadata/1118",
+ "librarySectionID" : "library-movies",
+ "librarySectionTitle" : "Movies",
+ "originalTitle" : "Tell Your Children",
+ "originallyAvailableAt" : "1938-06-15",
+ "rating" : 3.9,
+ "ratingImage" : "rottentomatoes://image.rating.rotten",
+ "ratingKey" : "1118",
+ "studio" : "G and H Productions",
+ "summary" : "A school principal warns parents with a sensationalized story of teenagers drawn into a marijuana dealer’s parties. This anti-drug melodrama follows the ensuing deception, violence, and tragedy through the exaggerated claims of its cautionary tale.",
+ "thumb" : "/mock/art/movies/reefer-madness/poster.png",
+ "title" : "Reefer Madness",
+ "titleSort" : "Reefer Madness",
+ "type" : "movie",
+ "viewCount" : 0,
+ "year" : 1938
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://catalog.afi.com/Catalog/MovieDetails/7885",
+ "https://www.rottentomatoes.com/m/reefer_madness",
+ "https://watch.plex.tv/movie/tell-your-children-1938",
+ "https://www.imdb.com/title/tt0028346/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "art" : "/mock/art/movies/charade/backdrop.jpg",
+ "key" : "/library/metadata/1191",
+ "librarySectionID" : "library-movies",
+ "librarySectionTitle" : "Movies",
+ "ratingKey" : "1191",
+ "subtype" : "trailer",
+ "summary" : "A trailer for Stanley Donen’s romantic mystery.",
+ "thumb" : "/mock/art/movies/charade/poster.png",
+ "title" : "Charade — Trailer",
+ "type" : "clip",
+ "year" : 1963
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.rottentomatoes.com/m/1003883-charade"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 14400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Director" : [
+ {
+ "id" : 50022,
+ "order" : 0,
+ "tag" : "John Newland",
+ "tagKey" : "50022"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Mystery"
+ },
+ {
+ "tag" : "Drama"
+ },
+ {
+ "tag" : "Fantasy"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0044229"
+ }
+ ],
+ "Producer" : [
+ {
+ "id" : 50023,
+ "order" : 0,
+ "tag" : "Collier Young",
+ "tagKey" : "50023"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 8.1
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50022,
+ "order" : 0,
+ "role" : "Host",
+ "tag" : "John Newland",
+ "tagKey" : "50022"
+ }
+ ],
+ "art" : "/mock/art/tv/one-step-beyond/backdrop.jpg",
+ "childCount" : 2,
+ "key" : "/library/metadata/2103/children",
+ "leafCount" : 3,
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1959-01-20",
+ "ratingKey" : "2103",
+ "summary" : "An anthology hosted by John Newland dramatizes accounts of unexplained and paranormal events.",
+ "thumb" : "/mock/art/tv/one-step-beyond/poster.png",
+ "title" : "One Step Beyond",
+ "titleSort" : "One Step Beyond",
+ "type" : "show",
+ "viewedLeafCount" : 0,
+ "year" : 1959
+ },
+ "relatedIDs" : [
+ "2102",
+ "2101"
+ ],
+ "sources" : [
+ "https://watch.plex.tv/show/one-step-beyond",
+ "https://www.imdb.com/title/tt0044229/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 18000,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Director" : [
+ {
+ "id" : 50024,
+ "order" : 0,
+ "tag" : "Ozzie Nelson",
+ "tagKey" : "50024"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Comedy"
+ },
+ {
+ "tag" : "Family"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0044230"
+ }
+ ],
+ "Producer" : [
+ {
+ "id" : 50024,
+ "order" : 0,
+ "tag" : "Ozzie Nelson",
+ "tagKey" : "50024"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7.4
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50024,
+ "order" : 0,
+ "role" : "Ozzie",
+ "tag" : "Ozzie Nelson",
+ "tagKey" : "50024"
+ },
+ {
+ "id" : 50025,
+ "order" : 1,
+ "role" : "Harriet",
+ "tag" : "Harriet Nelson",
+ "tagKey" : "50025"
+ },
+ {
+ "id" : 50026,
+ "order" : 2,
+ "role" : "David",
+ "tag" : "David Nelson",
+ "tagKey" : "50026"
+ },
+ {
+ "id" : 50027,
+ "order" : 3,
+ "role" : "Ricky",
+ "tag" : "Ricky Nelson",
+ "tagKey" : "50027"
+ },
+ {
+ "id" : 50028,
+ "order" : 4,
+ "role" : "Thorny",
+ "tag" : "Don DeFore",
+ "tagKey" : "50028"
+ }
+ ],
+ "art" : "/mock/art/tv/adventures-of-ozzie-and-harriet/backdrop.jpg",
+ "childCount" : 1,
+ "key" : "/library/metadata/2102/children",
+ "leafCount" : 1,
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1952-10-03",
+ "ratingKey" : "2102",
+ "summary" : "Ozzie and Harriet Nelson navigate family life with sons David and Ricky, whose school days give way to dating, marriage, and work.",
+ "thumb" : "/mock/art/tv/adventures-of-ozzie-and-harriet/poster.png",
+ "title" : "The Adventures of Ozzie and Harriet",
+ "titleSort" : "Adventures of Ozzie and Harriet",
+ "type" : "show",
+ "viewedLeafCount" : 1,
+ "year" : 1952
+ },
+ "relatedIDs" : [
+ "2103",
+ "2101"
+ ],
+ "sources" : [
+ "https://watch.plex.tv/show/the-adventures-of-ozzie-and-harriet",
+ "https://www.imdb.com/title/tt0044230/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 21600,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Director" : [
+ {
+ "id" : 50033,
+ "order" : 0,
+ "tag" : "Jean Yarbrough",
+ "tagKey" : "50033"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Comedy"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0052442"
+ }
+ ],
+ "Producer" : [
+
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7.8
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50029,
+ "order" : 0,
+ "role" : "Bud Abbott",
+ "tag" : "Bud Abbott",
+ "tagKey" : "50029"
+ },
+ {
+ "id" : 50030,
+ "order" : 1,
+ "role" : "Lou Costello",
+ "tag" : "Lou Costello",
+ "tagKey" : "50030"
+ },
+ {
+ "id" : 50031,
+ "order" : 2,
+ "role" : "Mr. Fields",
+ "tag" : "Sidney Fields",
+ "tagKey" : "50031"
+ },
+ {
+ "id" : 50032,
+ "order" : 3,
+ "role" : "Hillary Brooke",
+ "tag" : "Hillary Brooke",
+ "tagKey" : "50032"
+ }
+ ],
+ "art" : "/mock/art/tv/abbott-and-costello/backdrop.jpg",
+ "childCount" : 1,
+ "key" : "/library/metadata/2101/children",
+ "leafCount" : 1,
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "ratingKey" : "2101",
+ "summary" : "Bud and Lou struggle to find work and pay their boarding-house rent, turning everyday situations into comic routines and misunderstandings.",
+ "thumb" : "/mock/art/tv/abbott-and-costello/poster.png",
+ "title" : "The Abbott and Costello Show",
+ "titleSort" : "Abbott and Costello Show",
+ "type" : "show",
+ "viewedLeafCount" : 0,
+ "year" : 1952
+ },
+ "relatedIDs" : [
+ "2103",
+ "2102"
+ ],
+ "sources" : [
+ "https://watch.plex.tv/show/the-abbott-and-costello-show",
+ "https://www.imdb.com/title/tt0052442/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "art" : "/mock/art/tv/one-step-beyond/backdrop.jpg",
+ "childCount" : 2,
+ "index" : 1,
+ "key" : "/library/metadata/2301/children",
+ "leafCount" : 2,
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "parentRatingKey" : "2103",
+ "parentThumb" : "/mock/art/tv/one-step-beyond/poster.png",
+ "parentTitle" : "One Step Beyond",
+ "ratingKey" : "2301",
+ "thumb" : "/mock/art/tv/one-step-beyond/poster.png",
+ "title" : "Season 1",
+ "type" : "season",
+ "viewedLeafCount" : 0,
+ "year" : 1959
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://watch.plex.tv/show/one-step-beyond"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "art" : "/mock/art/tv/one-step-beyond/backdrop.jpg",
+ "childCount" : 1,
+ "index" : 2,
+ "key" : "/library/metadata/2302/children",
+ "leafCount" : 1,
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "parentRatingKey" : "2103",
+ "parentThumb" : "/mock/art/tv/one-step-beyond/poster.png",
+ "parentTitle" : "One Step Beyond",
+ "ratingKey" : "2302",
+ "thumb" : "/mock/art/tv/one-step-beyond/poster.png",
+ "title" : "Season 2",
+ "type" : "season",
+ "viewedLeafCount" : 0,
+ "year" : 1959
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://watch.plex.tv/show/one-step-beyond"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "art" : "/mock/art/tv/adventures-of-ozzie-and-harriet/backdrop.jpg",
+ "childCount" : 1,
+ "index" : 1,
+ "key" : "/library/metadata/2303/children",
+ "leafCount" : 1,
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "parentRatingKey" : "2102",
+ "parentThumb" : "/mock/art/tv/adventures-of-ozzie-and-harriet/poster.png",
+ "parentTitle" : "The Adventures of Ozzie and Harriet",
+ "ratingKey" : "2303",
+ "thumb" : "/mock/art/tv/adventures-of-ozzie-and-harriet/poster.png",
+ "title" : "Season 1",
+ "type" : "season",
+ "viewedLeafCount" : 1,
+ "year" : 1952
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://watch.plex.tv/show/the-adventures-of-ozzie-and-harriet"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "art" : "/mock/art/tv/abbott-and-costello/backdrop.jpg",
+ "childCount" : 1,
+ "index" : 1,
+ "key" : "/library/metadata/2304/children",
+ "leafCount" : 1,
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "parentRatingKey" : "2101",
+ "parentThumb" : "/mock/art/tv/abbott-and-costello/poster.png",
+ "parentTitle" : "The Abbott and Costello Show",
+ "ratingKey" : "2304",
+ "thumb" : "/mock/art/tv/abbott-and-costello/poster.png",
+ "title" : "Season 1",
+ "type" : "season",
+ "viewedLeafCount" : 0,
+ "year" : 1952
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://watch.plex.tv/show/the-abbott-and-costello-show"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Director" : [
+ {
+ "id" : 50022,
+ "order" : 0,
+ "tag" : "John Newland",
+ "tagKey" : "50022"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Mystery"
+ },
+ {
+ "tag" : "Drama"
+ },
+ {
+ "tag" : "Fantasy"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0507807"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7.1
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50034,
+ "order" : 0,
+ "role" : "Sally Conroy / Karen Wharton",
+ "tag" : "Virginia Leith",
+ "tagKey" : "50034"
+ },
+ {
+ "id" : 50035,
+ "order" : 1,
+ "role" : "Matt Conroy",
+ "tag" : "Skip Homeier",
+ "tagKey" : "50035"
+ },
+ {
+ "id" : 50036,
+ "order" : 2,
+ "role" : "Dr. Alexander Slawson",
+ "tag" : "Harry Townes",
+ "tagKey" : "50036"
+ }
+ ],
+ "art" : "/mock/art/tv/one-step-beyond/backdrop.jpg",
+ "duration" : 1550000,
+ "grandparentRatingKey" : "2103",
+ "grandparentThumb" : "/mock/art/tv/one-step-beyond/poster.png",
+ "grandparentTitle" : "One Step Beyond",
+ "index" : 1,
+ "key" : "/library/metadata/2201",
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1959-01-20",
+ "parentIndex" : 1,
+ "parentRatingKey" : "2301",
+ "parentThumb" : "/mock/art/tv/one-step-beyond/poster.png",
+ "parentTitle" : "Season 1",
+ "ratingKey" : "2201",
+ "summary" : "On their honeymoon, a bride begins behaving as though she is someone else, alarming her husband.",
+ "title" : "The Bride Possessed",
+ "type" : "episode",
+ "viewCount" : 0,
+ "viewOffset" : 420000,
+ "year" : 1959
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://watch.plex.tv/show/one-step-beyond/season/1/episode/1",
+ "https://www.imdb.com/title/tt0507807/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Director" : [
+ {
+ "id" : 50022,
+ "order" : 0,
+ "tag" : "John Newland",
+ "tagKey" : "50022"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Mystery"
+ },
+ {
+ "tag" : "Drama"
+ },
+ {
+ "tag" : "Fantasy"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0507795"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7.1
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50037,
+ "order" : 0,
+ "role" : "Eric Farley",
+ "tag" : "Patrick Macnee",
+ "tagKey" : "50037"
+ },
+ {
+ "id" : 50038,
+ "order" : 1,
+ "role" : "Grace Montgomery Farley",
+ "tag" : "Barbara Lord",
+ "tagKey" : "50038"
+ }
+ ],
+ "art" : "/mock/art/tv/one-step-beyond/backdrop.jpg",
+ "duration" : 1550000,
+ "grandparentRatingKey" : "2103",
+ "grandparentThumb" : "/mock/art/tv/one-step-beyond/poster.png",
+ "grandparentTitle" : "One Step Beyond",
+ "index" : 2,
+ "key" : "/library/metadata/2204",
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1959-01-27",
+ "parentIndex" : 1,
+ "parentRatingKey" : "2301",
+ "parentThumb" : "/mock/art/tv/one-step-beyond/poster.png",
+ "parentTitle" : "Season 1",
+ "ratingKey" : "2204",
+ "summary" : "A woman troubled by dreams of drowning learns that her honeymoon will take her aboard the Titanic.",
+ "title" : "Night of April 14th",
+ "type" : "episode",
+ "viewCount" : 0,
+ "year" : 1959
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://watch.plex.tv/show/one-step-beyond/season/1/episode/2",
+ "https://www.imdb.com/title/tt0507795/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Director" : [
+ {
+ "id" : 50022,
+ "order" : 0,
+ "tag" : "John Newland",
+ "tagKey" : "50022"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Mystery"
+ },
+ {
+ "tag" : "Drama"
+ },
+ {
+ "tag" : "Fantasy"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0507773"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 6.6
+ }
+ ],
+ "Role" : [
+
+ ],
+ "art" : "/mock/art/tv/one-step-beyond/backdrop.jpg",
+ "duration" : 1515000,
+ "grandparentRatingKey" : "2103",
+ "grandparentThumb" : "/mock/art/tv/one-step-beyond/poster.png",
+ "grandparentTitle" : "One Step Beyond",
+ "index" : 1,
+ "key" : "/library/metadata/2205",
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1959-09-15",
+ "parentIndex" : 2,
+ "parentRatingKey" : "2302",
+ "parentThumb" : "/mock/art/tv/one-step-beyond/poster.png",
+ "parentTitle" : "Season 2",
+ "ratingKey" : "2205",
+ "summary" : "A reluctant blood donor fears that saving a woman will create a psychic connection to her future.",
+ "title" : "Delusion",
+ "type" : "episode",
+ "viewCount" : 0,
+ "year" : 1959
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://watch.plex.tv/en-GB/show/one-step-beyond/season/2/episode/1",
+ "https://www.imdb.com/title/tt0507773/",
+ "https://www.imdb.com/search/title/?series=tt0052442"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Director" : [
+ {
+ "id" : 50024,
+ "order" : 0,
+ "tag" : "Ozzie Nelson",
+ "tagKey" : "50024"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Comedy"
+ },
+ {
+ "tag" : "Family"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7.5
+ }
+ ],
+ "Role" : [
+
+ ],
+ "art" : "/mock/art/tv/adventures-of-ozzie-and-harriet/backdrop.jpg",
+ "duration" : 1609000,
+ "grandparentRatingKey" : "2102",
+ "grandparentThumb" : "/mock/art/tv/adventures-of-ozzie-and-harriet/poster.png",
+ "grandparentTitle" : "The Adventures of Ozzie and Harriet",
+ "index" : 7,
+ "key" : "/library/metadata/2202",
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1952-11-14",
+ "parentIndex" : 1,
+ "parentRatingKey" : "2303",
+ "parentThumb" : "/mock/art/tv/adventures-of-ozzie-and-harriet/poster.png",
+ "parentTitle" : "Season 1",
+ "ratingKey" : "2202",
+ "summary" : "David takes a babysitting job, and an anxious Ozzie decides to check on him.",
+ "title" : "David the Babysitter",
+ "type" : "episode",
+ "viewCount" : 1,
+ "year" : 1952
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://watch.plex.tv/show/the-adventures-of-ozzie-and-harriet/season/1/episode/7",
+ "https://thetvdb.com/series/the-adventures-of-ozzie-and-harriet/allseasons/official",
+ "https://www.imdb.com/title/tt0044230/episodes/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Director" : [
+ {
+ "id" : 50033,
+ "order" : 0,
+ "tag" : "Jean Yarbrough",
+ "tagKey" : "50033"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Comedy"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0504552"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7.7
+ }
+ ],
+ "Role" : [
+
+ ],
+ "art" : "/mock/art/tv/abbott-and-costello/backdrop.jpg",
+ "duration" : 1572000,
+ "grandparentRatingKey" : "2101",
+ "grandparentThumb" : "/mock/art/tv/abbott-and-costello/poster.png",
+ "grandparentTitle" : "The Abbott and Costello Show",
+ "index" : 2,
+ "key" : "/library/metadata/2203",
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1952-12-12",
+ "parentIndex" : 1,
+ "parentRatingKey" : "2304",
+ "parentThumb" : "/mock/art/tv/abbott-and-costello/poster.png",
+ "parentTitle" : "Season 1",
+ "ratingKey" : "2203",
+ "summary" : "Lou’s toothache leads him to a dentist, then into an increasingly unlikely search for free treatment.",
+ "title" : "The Dentist's Office",
+ "type" : "episode",
+ "viewCount" : 0,
+ "year" : 1952
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://watch.plex.tv/show/the-abbott-and-costello-show/season/1/episode/2",
+ "https://www.imdb.com/title/tt0504552/",
+ "https://www.imdb.com/title/tt0044229/episodes/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 25200,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "childCount" : 1,
+ "key" : "/library/metadata/3001/children",
+ "leafCount" : 27,
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "ratingKey" : "3001",
+ "summary" : "Bram Stoker (1847–1912), author of Dracula.",
+ "thumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "title" : "Bram Stoker",
+ "type" : "artist",
+ "viewedLeafCount" : 0
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/dracula-by-bram-stoker"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 28800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "childCount" : 2,
+ "key" : "/library/metadata/3002/children",
+ "leafCount" : 40,
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "ratingKey" : "3002",
+ "summary" : "H. G. Wells (1866–1946), author of The Time Machine and The War of the Worlds.",
+ "thumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "title" : "H. G. Wells",
+ "type" : "artist",
+ "viewedLeafCount" : 0
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-time-machine-by-h-g-wells-2/",
+ "https://www.gutenberg.org/files/26290/26290-index.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 28800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "childCount" : 27,
+ "duration" : 59468000,
+ "key" : "/library/metadata/3101/children",
+ "leafCount" : 27,
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2006-05-10",
+ "parentRatingKey" : "3001",
+ "parentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "parentTitle" : "Bram Stoker",
+ "ratingKey" : "3101",
+ "studio" : "LibriVox",
+ "summary" : "Bram Stoker’s 1897 novel follows Jonathan Harker, Mina Murray, and their allies as they confront Count Dracula. This LibriVox edition is read by volunteers.",
+ "thumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "title" : "Dracula",
+ "titleSort" : "Dracula",
+ "type" : "album",
+ "viewedLeafCount" : 0,
+ "year" : 2006
+ },
+ "relatedIDs" : [
+ "3102",
+ "3103"
+ ],
+ "sources" : [
+ "https://librivox.org/dracula-by-bram-stoker"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "duration" : 2218000,
+ "grandparentRatingKey" : "3001",
+ "grandparentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "grandparentTitle" : "Bram Stoker",
+ "index" : 1,
+ "key" : "/library/metadata/32101",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3101",
+ "parentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "parentTitle" : "Dracula",
+ "ratingKey" : "32101",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "title" : "Chapter 01",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/dracula-by-bram-stoker"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "duration" : 1796000,
+ "grandparentRatingKey" : "3001",
+ "grandparentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "grandparentTitle" : "Bram Stoker",
+ "index" : 2,
+ "key" : "/library/metadata/32102",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3101",
+ "parentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "parentTitle" : "Dracula",
+ "ratingKey" : "32102",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "title" : "Chapter 02",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/dracula-by-bram-stoker"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "duration" : 2825000,
+ "grandparentRatingKey" : "3001",
+ "grandparentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "grandparentTitle" : "Bram Stoker",
+ "index" : 3,
+ "key" : "/library/metadata/32103",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3101",
+ "parentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "parentTitle" : "Dracula",
+ "ratingKey" : "32103",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "title" : "Chapter 03",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/dracula-by-bram-stoker"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "duration" : 2176000,
+ "grandparentRatingKey" : "3001",
+ "grandparentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "grandparentTitle" : "Bram Stoker",
+ "index" : 4,
+ "key" : "/library/metadata/32104",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3101",
+ "parentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "parentTitle" : "Dracula",
+ "ratingKey" : "32104",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "title" : "Chapter 04",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/dracula-by-bram-stoker"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "duration" : 1101000,
+ "grandparentRatingKey" : "3001",
+ "grandparentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "grandparentTitle" : "Bram Stoker",
+ "index" : 5,
+ "key" : "/library/metadata/32105",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3101",
+ "parentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "parentTitle" : "Dracula",
+ "ratingKey" : "32105",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "title" : "Chapter 05",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/dracula-by-bram-stoker"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "duration" : 1910000,
+ "grandparentRatingKey" : "3001",
+ "grandparentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "grandparentTitle" : "Bram Stoker",
+ "index" : 6,
+ "key" : "/library/metadata/32106",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3101",
+ "parentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "parentTitle" : "Dracula",
+ "ratingKey" : "32106",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "title" : "Chapter 06",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/dracula-by-bram-stoker"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "duration" : 2507000,
+ "grandparentRatingKey" : "3001",
+ "grandparentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "grandparentTitle" : "Bram Stoker",
+ "index" : 7,
+ "key" : "/library/metadata/32107",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3101",
+ "parentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "parentTitle" : "Dracula",
+ "ratingKey" : "32107",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "title" : "Chapter 07",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/dracula-by-bram-stoker"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "duration" : 2317000,
+ "grandparentRatingKey" : "3001",
+ "grandparentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "grandparentTitle" : "Bram Stoker",
+ "index" : 8,
+ "key" : "/library/metadata/32108",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3101",
+ "parentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "parentTitle" : "Dracula",
+ "ratingKey" : "32108",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "title" : "Chapter 08",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/dracula-by-bram-stoker"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "duration" : 1953000,
+ "grandparentRatingKey" : "3001",
+ "grandparentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "grandparentTitle" : "Bram Stoker",
+ "index" : 9,
+ "key" : "/library/metadata/32109",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3101",
+ "parentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "parentTitle" : "Dracula",
+ "ratingKey" : "32109",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "title" : "Chapter 09",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/dracula-by-bram-stoker"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "duration" : 2000000,
+ "grandparentRatingKey" : "3001",
+ "grandparentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "grandparentTitle" : "Bram Stoker",
+ "index" : 10,
+ "key" : "/library/metadata/32110",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3101",
+ "parentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "parentTitle" : "Dracula",
+ "ratingKey" : "32110",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "title" : "Chapter 10",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/dracula-by-bram-stoker"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "duration" : 2120000,
+ "grandparentRatingKey" : "3001",
+ "grandparentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "grandparentTitle" : "Bram Stoker",
+ "index" : 11,
+ "key" : "/library/metadata/32111",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3101",
+ "parentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "parentTitle" : "Dracula",
+ "ratingKey" : "32111",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "title" : "Chapter 11",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/dracula-by-bram-stoker"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "duration" : 2650000,
+ "grandparentRatingKey" : "3001",
+ "grandparentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "grandparentTitle" : "Bram Stoker",
+ "index" : 12,
+ "key" : "/library/metadata/32112",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3101",
+ "parentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "parentTitle" : "Dracula",
+ "ratingKey" : "32112",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "title" : "Chapter 12",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/dracula-by-bram-stoker"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "duration" : 2688000,
+ "grandparentRatingKey" : "3001",
+ "grandparentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "grandparentTitle" : "Bram Stoker",
+ "index" : 13,
+ "key" : "/library/metadata/32113",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3101",
+ "parentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "parentTitle" : "Dracula",
+ "ratingKey" : "32113",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "title" : "Chapter 13",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/dracula-by-bram-stoker"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "duration" : 2355000,
+ "grandparentRatingKey" : "3001",
+ "grandparentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "grandparentTitle" : "Bram Stoker",
+ "index" : 14,
+ "key" : "/library/metadata/32114",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3101",
+ "parentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "parentTitle" : "Dracula",
+ "ratingKey" : "32114",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "title" : "Chapter 14",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/dracula-by-bram-stoker"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "duration" : 2371000,
+ "grandparentRatingKey" : "3001",
+ "grandparentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "grandparentTitle" : "Bram Stoker",
+ "index" : 15,
+ "key" : "/library/metadata/32115",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3101",
+ "parentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "parentTitle" : "Dracula",
+ "ratingKey" : "32115",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "title" : "Chapter 15",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/dracula-by-bram-stoker"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "duration" : 1849000,
+ "grandparentRatingKey" : "3001",
+ "grandparentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "grandparentTitle" : "Bram Stoker",
+ "index" : 16,
+ "key" : "/library/metadata/32116",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3101",
+ "parentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "parentTitle" : "Dracula",
+ "ratingKey" : "32116",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "title" : "Chapter 16",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/dracula-by-bram-stoker"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "duration" : 2044000,
+ "grandparentRatingKey" : "3001",
+ "grandparentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "grandparentTitle" : "Bram Stoker",
+ "index" : 17,
+ "key" : "/library/metadata/32117",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3101",
+ "parentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "parentTitle" : "Dracula",
+ "ratingKey" : "32117",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "title" : "Chapter 17",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/dracula-by-bram-stoker"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "duration" : 2137000,
+ "grandparentRatingKey" : "3001",
+ "grandparentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "grandparentTitle" : "Bram Stoker",
+ "index" : 18,
+ "key" : "/library/metadata/32118",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3101",
+ "parentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "parentTitle" : "Dracula",
+ "ratingKey" : "32118",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "title" : "Chapter 18",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/dracula-by-bram-stoker"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "duration" : 1810000,
+ "grandparentRatingKey" : "3001",
+ "grandparentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "grandparentTitle" : "Bram Stoker",
+ "index" : 19,
+ "key" : "/library/metadata/32119",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3101",
+ "parentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "parentTitle" : "Dracula",
+ "ratingKey" : "32119",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "title" : "Chapter 19",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/dracula-by-bram-stoker"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "duration" : 2130000,
+ "grandparentRatingKey" : "3001",
+ "grandparentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "grandparentTitle" : "Bram Stoker",
+ "index" : 20,
+ "key" : "/library/metadata/32120",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3101",
+ "parentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "parentTitle" : "Dracula",
+ "ratingKey" : "32120",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "title" : "Chapter 20",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/dracula-by-bram-stoker"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "duration" : 1885000,
+ "grandparentRatingKey" : "3001",
+ "grandparentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "grandparentTitle" : "Bram Stoker",
+ "index" : 21,
+ "key" : "/library/metadata/32121",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3101",
+ "parentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "parentTitle" : "Dracula",
+ "ratingKey" : "32121",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "title" : "Chapter 21",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/dracula-by-bram-stoker"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "duration" : 1960000,
+ "grandparentRatingKey" : "3001",
+ "grandparentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "grandparentTitle" : "Bram Stoker",
+ "index" : 22,
+ "key" : "/library/metadata/32122",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3101",
+ "parentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "parentTitle" : "Dracula",
+ "ratingKey" : "32122",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "title" : "Chapter 22",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/dracula-by-bram-stoker"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "duration" : 1716000,
+ "grandparentRatingKey" : "3001",
+ "grandparentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "grandparentTitle" : "Bram Stoker",
+ "index" : 23,
+ "key" : "/library/metadata/32123",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3101",
+ "parentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "parentTitle" : "Dracula",
+ "ratingKey" : "32123",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "title" : "Chapter 23",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/dracula-by-bram-stoker"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "duration" : 2522000,
+ "grandparentRatingKey" : "3001",
+ "grandparentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "grandparentTitle" : "Bram Stoker",
+ "index" : 24,
+ "key" : "/library/metadata/32124",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3101",
+ "parentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "parentTitle" : "Dracula",
+ "ratingKey" : "32124",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "title" : "Chapter 24",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/dracula-by-bram-stoker"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "duration" : 2124000,
+ "grandparentRatingKey" : "3001",
+ "grandparentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "grandparentTitle" : "Bram Stoker",
+ "index" : 25,
+ "key" : "/library/metadata/32125",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3101",
+ "parentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "parentTitle" : "Dracula",
+ "ratingKey" : "32125",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "title" : "Chapter 25",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/dracula-by-bram-stoker"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "duration" : 2615000,
+ "grandparentRatingKey" : "3001",
+ "grandparentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "grandparentTitle" : "Bram Stoker",
+ "index" : 26,
+ "key" : "/library/metadata/32126",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3101",
+ "parentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "parentTitle" : "Dracula",
+ "ratingKey" : "32126",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "title" : "Chapter 26",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/dracula-by-bram-stoker"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "duration" : 3689000,
+ "grandparentRatingKey" : "3001",
+ "grandparentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "grandparentTitle" : "Bram Stoker",
+ "index" : 27,
+ "key" : "/library/metadata/32127",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3101",
+ "parentThumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "parentTitle" : "Dracula",
+ "ratingKey" : "32127",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/dracula/cover.png",
+ "title" : "Chapter 27",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/dracula-by-bram-stoker"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 32400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "childCount" : 13,
+ "duration" : 13641000,
+ "key" : "/library/metadata/3102/children",
+ "leafCount" : 13,
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2012-01-08",
+ "parentRatingKey" : "3002",
+ "parentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "parentTitle" : "H. G. Wells",
+ "ratingKey" : "3102",
+ "studio" : "LibriVox",
+ "summary" : "A Victorian inventor travels to the distant future and encounters the Eloi and Morlocks. This is the fourth LibriVox version of Wells’s 1895 novella.",
+ "thumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "title" : "The Time Machine",
+ "titleSort" : "Time Machine",
+ "type" : "album",
+ "viewedLeafCount" : 0,
+ "year" : 2012
+ },
+ "relatedIDs" : [
+ "3101",
+ "3103"
+ ],
+ "sources" : [
+ "https://librivox.org/the-time-machine-by-h-g-wells-2/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 1290000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 1,
+ "key" : "/library/metadata/32201",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3102",
+ "parentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "parentTitle" : "The Time Machine",
+ "ratingKey" : "32201",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "title" : "Chapter I",
+ "type" : "track",
+ "year" : 2012
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-time-machine-by-h-g-wells-2/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 839000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 2,
+ "key" : "/library/metadata/32202",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3102",
+ "parentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "parentTitle" : "The Time Machine",
+ "ratingKey" : "32202",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "title" : "Chapter II",
+ "type" : "track",
+ "year" : 2012
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-time-machine-by-h-g-wells-2/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 926000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 3,
+ "key" : "/library/metadata/32203",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3102",
+ "parentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "parentTitle" : "The Time Machine",
+ "ratingKey" : "32203",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "title" : "Chapter III",
+ "type" : "track",
+ "year" : 2012
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-time-machine-by-h-g-wells-2/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 1670000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 4,
+ "key" : "/library/metadata/32204",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3102",
+ "parentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "parentTitle" : "The Time Machine",
+ "ratingKey" : "32204",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "title" : "Chapter IV",
+ "type" : "track",
+ "year" : 2012
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-time-machine-by-h-g-wells-2/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 2579000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 5,
+ "key" : "/library/metadata/32205",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3102",
+ "parentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "parentTitle" : "The Time Machine",
+ "ratingKey" : "32205",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "title" : "Chapter V",
+ "type" : "track",
+ "year" : 2012
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-time-machine-by-h-g-wells-2/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 866000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 6,
+ "key" : "/library/metadata/32206",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3102",
+ "parentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "parentTitle" : "The Time Machine",
+ "ratingKey" : "32206",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "title" : "Chapter VI",
+ "type" : "track",
+ "year" : 2012
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-time-machine-by-h-g-wells-2/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 1100000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 7,
+ "key" : "/library/metadata/32207",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3102",
+ "parentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "parentTitle" : "The Time Machine",
+ "ratingKey" : "32207",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "title" : "Chapter VII",
+ "type" : "track",
+ "year" : 2012
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-time-machine-by-h-g-wells-2/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 1097000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 8,
+ "key" : "/library/metadata/32208",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3102",
+ "parentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "parentTitle" : "The Time Machine",
+ "ratingKey" : "32208",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "title" : "Chapter VIII",
+ "type" : "track",
+ "year" : 2012
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-time-machine-by-h-g-wells-2/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 1049000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 9,
+ "key" : "/library/metadata/32209",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3102",
+ "parentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "parentTitle" : "The Time Machine",
+ "ratingKey" : "32209",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "title" : "Chapter IX",
+ "type" : "track",
+ "year" : 2012
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-time-machine-by-h-g-wells-2/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 463000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 10,
+ "key" : "/library/metadata/32210",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3102",
+ "parentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "parentTitle" : "The Time Machine",
+ "ratingKey" : "32210",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "title" : "Chapter X",
+ "type" : "track",
+ "year" : 2012
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-time-machine-by-h-g-wells-2/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 803000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 11,
+ "key" : "/library/metadata/32211",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3102",
+ "parentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "parentTitle" : "The Time Machine",
+ "ratingKey" : "32211",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "title" : "Chapter XI",
+ "type" : "track",
+ "year" : 2012
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-time-machine-by-h-g-wells-2/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 800000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 12,
+ "key" : "/library/metadata/32212",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3102",
+ "parentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "parentTitle" : "The Time Machine",
+ "ratingKey" : "32212",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "title" : "Chapter XII",
+ "type" : "track",
+ "year" : 2012
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-time-machine-by-h-g-wells-2/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 159000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 13,
+ "key" : "/library/metadata/32213",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3102",
+ "parentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "parentTitle" : "The Time Machine",
+ "ratingKey" : "32213",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "title" : "Epilogue",
+ "type" : "track",
+ "year" : 2012
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-time-machine-by-h-g-wells-2/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 36000,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "childCount" : 27,
+ "duration" : 23735000,
+ "key" : "/library/metadata/3103/children",
+ "leafCount" : 27,
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentRatingKey" : "3002",
+ "parentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "parentTitle" : "H. G. Wells",
+ "ratingKey" : "3103",
+ "studio" : "LibriVox",
+ "summary" : "Wells’s 1898 novel depicts a Martian invasion of England. This LibriVox reading by Rebecca is cataloged by Project Gutenberg.",
+ "thumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "title" : "The War of the Worlds",
+ "titleSort" : "War of the Worlds",
+ "type" : "album",
+ "viewedLeafCount" : 0,
+ "year" : 2008
+ },
+ "relatedIDs" : [
+ "3101",
+ "3102"
+ ],
+ "sources" : [
+ "https://www.gutenberg.org/files/26290/26290-index.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 939000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 1,
+ "key" : "/library/metadata/32301",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3103",
+ "parentThumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "parentTitle" : "The War of the Worlds",
+ "ratingKey" : "32301",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "title" : "Book 1, Chapter 1",
+ "type" : "track",
+ "viewCount" : 0,
+ "viewOffset" : 120000,
+ "year" : 2008
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.gutenberg.org/files/26290/26290-index.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 567000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 2,
+ "key" : "/library/metadata/32302",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3103",
+ "parentThumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "parentTitle" : "The War of the Worlds",
+ "ratingKey" : "32302",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "title" : "Book 1, Chapter 2",
+ "type" : "track",
+ "year" : 2008
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.gutenberg.org/files/26290/26290-index.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 416000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 3,
+ "key" : "/library/metadata/32303",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3103",
+ "parentThumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "parentTitle" : "The War of the Worlds",
+ "ratingKey" : "32303",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "title" : "Book 1, Chapter 3",
+ "type" : "track",
+ "year" : 2008
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.gutenberg.org/files/26290/26290-index.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 479000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 4,
+ "key" : "/library/metadata/32304",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3103",
+ "parentThumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "parentTitle" : "The War of the Worlds",
+ "ratingKey" : "32304",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "title" : "Book 1, Chapter 4",
+ "type" : "track",
+ "year" : 2008
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.gutenberg.org/files/26290/26290-index.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 615000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 5,
+ "key" : "/library/metadata/32305",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3103",
+ "parentThumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "parentTitle" : "The War of the Worlds",
+ "ratingKey" : "32305",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "title" : "Book 1, Chapter 5",
+ "type" : "track",
+ "year" : 2008
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.gutenberg.org/files/26290/26290-index.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 356000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 6,
+ "key" : "/library/metadata/32306",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3103",
+ "parentThumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "parentTitle" : "The War of the Worlds",
+ "ratingKey" : "32306",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "title" : "Book 1, Chapter 6",
+ "type" : "track",
+ "year" : 2008
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.gutenberg.org/files/26290/26290-index.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 533000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 7,
+ "key" : "/library/metadata/32307",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3103",
+ "parentThumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "parentTitle" : "The War of the Worlds",
+ "ratingKey" : "32307",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "title" : "Book 1, Chapter 7",
+ "type" : "track",
+ "year" : 2008
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.gutenberg.org/files/26290/26290-index.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 413000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 8,
+ "key" : "/library/metadata/32308",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3103",
+ "parentThumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "parentTitle" : "The War of the Worlds",
+ "ratingKey" : "32308",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "title" : "Book 1, Chapter 8",
+ "type" : "track",
+ "year" : 2008
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.gutenberg.org/files/26290/26290-index.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 785000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 9,
+ "key" : "/library/metadata/32309",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3103",
+ "parentThumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "parentTitle" : "The War of the Worlds",
+ "ratingKey" : "32309",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "title" : "Book 1, Chapter 9",
+ "type" : "track",
+ "year" : 2008
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.gutenberg.org/files/26290/26290-index.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 850000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 10,
+ "key" : "/library/metadata/32310",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3103",
+ "parentThumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "parentTitle" : "The War of the Worlds",
+ "ratingKey" : "32310",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "title" : "Book 1, Chapter 10",
+ "type" : "track",
+ "year" : 2008
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.gutenberg.org/files/26290/26290-index.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 768000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 11,
+ "key" : "/library/metadata/32311",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3103",
+ "parentThumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "parentTitle" : "The War of the Worlds",
+ "ratingKey" : "32311",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "title" : "Book 1, Chapter 11",
+ "type" : "track",
+ "year" : 2008
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.gutenberg.org/files/26290/26290-index.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 1415000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 12,
+ "key" : "/library/metadata/32312",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3103",
+ "parentThumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "parentTitle" : "The War of the Worlds",
+ "ratingKey" : "32312",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "title" : "Book 1, Chapter 12",
+ "type" : "track",
+ "year" : 2008
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.gutenberg.org/files/26290/26290-index.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 752000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 13,
+ "key" : "/library/metadata/32313",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3103",
+ "parentThumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "parentTitle" : "The War of the Worlds",
+ "ratingKey" : "32313",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "title" : "Book 1, Chapter 13",
+ "type" : "track",
+ "year" : 2008
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.gutenberg.org/files/26290/26290-index.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 1445000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 14,
+ "key" : "/library/metadata/32314",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3103",
+ "parentThumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "parentTitle" : "The War of the Worlds",
+ "ratingKey" : "32314",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "title" : "Book 1, Chapter 14",
+ "type" : "track",
+ "year" : 2008
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.gutenberg.org/files/26290/26290-index.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 1155000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 15,
+ "key" : "/library/metadata/32315",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3103",
+ "parentThumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "parentTitle" : "The War of the Worlds",
+ "ratingKey" : "32315",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "title" : "Book 1, Chapter 15",
+ "type" : "track",
+ "year" : 2008
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.gutenberg.org/files/26290/26290-index.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 1694000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 16,
+ "key" : "/library/metadata/32316",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3103",
+ "parentThumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "parentTitle" : "The War of the Worlds",
+ "ratingKey" : "32316",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "title" : "Book 1, Chapter 16",
+ "type" : "track",
+ "year" : 2008
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.gutenberg.org/files/26290/26290-index.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 1333000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 17,
+ "key" : "/library/metadata/32317",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3103",
+ "parentThumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "parentTitle" : "The War of the Worlds",
+ "ratingKey" : "32317",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "title" : "Book 1, Chapter 17",
+ "type" : "track",
+ "year" : 2008
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.gutenberg.org/files/26290/26290-index.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 1013000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 18,
+ "key" : "/library/metadata/32318",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3103",
+ "parentThumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "parentTitle" : "The War of the Worlds",
+ "ratingKey" : "32318",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "title" : "Book 2, Chapter 1",
+ "type" : "track",
+ "year" : 2008
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.gutenberg.org/files/26290/26290-index.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 1329000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 19,
+ "key" : "/library/metadata/32319",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3103",
+ "parentThumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "parentTitle" : "The War of the Worlds",
+ "ratingKey" : "32319",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "title" : "Book 2, Chapter 2",
+ "type" : "track",
+ "year" : 2008
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.gutenberg.org/files/26290/26290-index.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 765000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 20,
+ "key" : "/library/metadata/32320",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3103",
+ "parentThumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "parentTitle" : "The War of the Worlds",
+ "ratingKey" : "32320",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "title" : "Book 2, Chapter 3",
+ "type" : "track",
+ "year" : 2008
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.gutenberg.org/files/26290/26290-index.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 658000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 21,
+ "key" : "/library/metadata/32321",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3103",
+ "parentThumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "parentTitle" : "The War of the Worlds",
+ "ratingKey" : "32321",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "title" : "Book 2, Chapter 4",
+ "type" : "track",
+ "year" : 2008
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.gutenberg.org/files/26290/26290-index.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 409000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 22,
+ "key" : "/library/metadata/32322",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3103",
+ "parentThumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "parentTitle" : "The War of the Worlds",
+ "ratingKey" : "32322",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "title" : "Book 2, Chapter 5",
+ "type" : "track",
+ "year" : 2008
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.gutenberg.org/files/26290/26290-index.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 516000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 23,
+ "key" : "/library/metadata/32323",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3103",
+ "parentThumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "parentTitle" : "The War of the Worlds",
+ "ratingKey" : "32323",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "title" : "Book 2, Chapter 6",
+ "type" : "track",
+ "year" : 2008
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.gutenberg.org/files/26290/26290-index.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 2052000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 24,
+ "key" : "/library/metadata/32324",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3103",
+ "parentThumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "parentTitle" : "The War of the Worlds",
+ "ratingKey" : "32324",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "title" : "Book 2, Chapter 7",
+ "type" : "track",
+ "year" : 2008
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.gutenberg.org/files/26290/26290-index.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 1227000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 25,
+ "key" : "/library/metadata/32325",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3103",
+ "parentThumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "parentTitle" : "The War of the Worlds",
+ "ratingKey" : "32325",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "title" : "Book 2, Chapter 8",
+ "type" : "track",
+ "year" : 2008
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.gutenberg.org/files/26290/26290-index.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 712000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 26,
+ "key" : "/library/metadata/32326",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3103",
+ "parentThumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "parentTitle" : "The War of the Worlds",
+ "ratingKey" : "32326",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "title" : "Book 2, Chapter 9",
+ "type" : "track",
+ "year" : 2008
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.gutenberg.org/files/26290/26290-index.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 5400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "duration" : 539000,
+ "grandparentRatingKey" : "3002",
+ "grandparentThumb" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "grandparentTitle" : "H. G. Wells",
+ "index" : 27,
+ "key" : "/library/metadata/32327",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3103",
+ "parentThumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "parentTitle" : "The War of the Worlds",
+ "ratingKey" : "32327",
+ "studio" : "LibriVox",
+ "thumb" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "title" : "Book 2, Chapter 10",
+ "type" : "track",
+ "year" : 2008
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.gutenberg.org/files/26290/26290-index.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 172800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Country" : [
+ {
+ "tag" : "United States"
+ }
+ ],
+ "Director" : [
+ {
+ "id" : 50081,
+ "order" : 0,
+ "tag" : "Joseph Depew",
+ "tagKey" : "50081"
+ },
+ {
+ "id" : 50082,
+ "order" : 1,
+ "tag" : "Richard Whorf",
+ "tagKey" : "50082"
+ },
+ {
+ "id" : 50083,
+ "order" : 2,
+ "tag" : "Ralph Levy",
+ "tagKey" : "50083"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Comedy"
+ },
+ {
+ "tag" : "Family"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0055662"
+ }
+ ],
+ "Producer" : [
+ {
+ "id" : 50084,
+ "order" : 0,
+ "tag" : "Paul Henning",
+ "tagKey" : "50084"
+ },
+ {
+ "id" : 50081,
+ "order" : 1,
+ "tag" : "Joseph Depew",
+ "tagKey" : "50081"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7.3
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50085,
+ "order" : 0,
+ "role" : "Jed Clampett",
+ "tag" : "Buddy Ebsen",
+ "tagKey" : "50085"
+ },
+ {
+ "id" : 50086,
+ "order" : 1,
+ "role" : "Granny",
+ "tag" : "Irene Ryan",
+ "tagKey" : "50086"
+ },
+ {
+ "id" : 50087,
+ "order" : 2,
+ "role" : "Elly May Clampett",
+ "tag" : "Donna Douglas",
+ "tagKey" : "50087"
+ },
+ {
+ "id" : 50088,
+ "order" : 3,
+ "role" : "Jethro Bodine",
+ "tag" : "Max Baer Jr.",
+ "tagKey" : "50088"
+ },
+ {
+ "id" : 50089,
+ "order" : 4,
+ "role" : "Milburn Drysdale",
+ "tag" : "Raymond Bailey",
+ "tagKey" : "50089"
+ },
+ {
+ "id" : 50090,
+ "order" : 5,
+ "role" : "Jane Hathaway",
+ "tag" : "Nancy Kulp",
+ "tagKey" : "50090"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50084,
+ "order" : 0,
+ "tag" : "Paul Henning",
+ "tagKey" : "50084"
+ }
+ ],
+ "art" : "/mock/art/studio/2104/backdrop.jpg",
+ "childCount" : 2,
+ "contentRating" : "TV-G",
+ "key" : "/library/metadata/2104/children",
+ "leafCount" : 3,
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1962-09-26",
+ "ratingKey" : "2104",
+ "studio" : "Filmways Television",
+ "summary" : "After discovering oil on their land, the Clampetts move from the Ozarks to Beverly Hills. Their rural habits bewilder their wealthy neighbors and the banker determined to keep their fortune.",
+ "thumb" : "/mock/art/studio/2104/poster.png",
+ "title" : "The Beverly Hillbillies",
+ "titleSort" : "Beverly Hillbillies",
+ "type" : "show",
+ "viewedLeafCount" : 0,
+ "year" : 1962
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://watch.plex.tv/show/the-beverly-hillbillies",
+ "https://www.imdb.com/title/tt0055662/fullcredits/",
+ "https://www.imdb.com/title/tt0055662/",
+ "https://www.fesfilms.com/public-domain/television/comedy.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 172800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "art" : "/mock/art/studio/2104/backdrop.jpg",
+ "childCount" : 2,
+ "index" : 1,
+ "key" : "/library/metadata/2305/children",
+ "leafCount" : 2,
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "parentRatingKey" : "2104",
+ "parentThumb" : "/mock/art/studio/2104/poster.png",
+ "parentTitle" : "The Beverly Hillbillies",
+ "ratingKey" : "2305",
+ "title" : "Season 1",
+ "type" : "season",
+ "viewedLeafCount" : 0,
+ "year" : 1962
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0055662/episodes/?season=1",
+ "https://www.fesfilms.com/public-domain/television/comedy.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 172800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Director" : [
+ {
+ "id" : 50083,
+ "order" : 0,
+ "tag" : "Ralph Levy",
+ "tagKey" : "50083"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Comedy"
+ },
+ {
+ "tag" : "Family"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0522598"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 8.4
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50085,
+ "order" : 0,
+ "role" : "Jed Clampett",
+ "tag" : "Buddy Ebsen",
+ "tagKey" : "50085"
+ },
+ {
+ "id" : 50086,
+ "order" : 1,
+ "role" : "Granny",
+ "tag" : "Irene Ryan",
+ "tagKey" : "50086"
+ },
+ {
+ "id" : 50087,
+ "order" : 2,
+ "role" : "Elly May Clampett",
+ "tag" : "Donna Douglas",
+ "tagKey" : "50087"
+ },
+ {
+ "id" : 50088,
+ "order" : 3,
+ "role" : "Jethro Bodine",
+ "tag" : "Max Baer Jr.",
+ "tagKey" : "50088"
+ },
+ {
+ "id" : 50089,
+ "order" : 4,
+ "role" : "Milburn Drysdale",
+ "tag" : "Raymond Bailey",
+ "tagKey" : "50089"
+ },
+ {
+ "id" : 50090,
+ "order" : 5,
+ "role" : "Jane Hathaway",
+ "tag" : "Nancy Kulp",
+ "tagKey" : "50090"
+ },
+ {
+ "id" : 50091,
+ "order" : 6,
+ "role" : "Cousin Pearl Bodine",
+ "tag" : "Bea Benaderet",
+ "tagKey" : "50091"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50084,
+ "order" : 0,
+ "tag" : "Paul Henning",
+ "tagKey" : "50084"
+ }
+ ],
+ "art" : "/mock/art/studio/2104/backdrop.jpg",
+ "duration" : 1914000,
+ "grandparentRatingKey" : "2104",
+ "grandparentThumb" : "/mock/art/studio/2104/poster.png",
+ "grandparentTitle" : "The Beverly Hillbillies",
+ "index" : 1,
+ "key" : "/library/metadata/2206",
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1962-09-26",
+ "parentIndex" : 1,
+ "parentRatingKey" : "2305",
+ "parentTitle" : "Season 1",
+ "ratingKey" : "2206",
+ "studio" : "Filmways Television",
+ "summary" : "Oil beneath Jed’s land makes the Clampetts millionaires. Cousin Pearl persuades the family to trade their mountain cabin for a mansion in Beverly Hills.",
+ "title" : "The Clampetts Strike Oil",
+ "type" : "episode",
+ "viewCount" : 0,
+ "year" : 1962
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0522598/",
+ "https://watch.plex.tv/show/the-beverly-hillbillies/season/1/episode/1",
+ "https://www.fesfilms.com/public-domain/television/comedy.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 172800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Director" : [
+ {
+ "id" : 50082,
+ "order" : 0,
+ "tag" : "Richard Whorf",
+ "tagKey" : "50082"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Comedy"
+ },
+ {
+ "tag" : "Family"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0786437"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 8.1
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50085,
+ "order" : 0,
+ "role" : "Jed Clampett",
+ "tag" : "Buddy Ebsen",
+ "tagKey" : "50085"
+ },
+ {
+ "id" : 50086,
+ "order" : 1,
+ "role" : "Granny",
+ "tag" : "Irene Ryan",
+ "tagKey" : "50086"
+ },
+ {
+ "id" : 50087,
+ "order" : 2,
+ "role" : "Elly May Clampett",
+ "tag" : "Donna Douglas",
+ "tagKey" : "50087"
+ },
+ {
+ "id" : 50088,
+ "order" : 3,
+ "role" : "Jethro Bodine",
+ "tag" : "Max Baer Jr.",
+ "tagKey" : "50088"
+ },
+ {
+ "id" : 50089,
+ "order" : 4,
+ "role" : "Milburn Drysdale",
+ "tag" : "Raymond Bailey",
+ "tagKey" : "50089"
+ },
+ {
+ "id" : 50090,
+ "order" : 5,
+ "role" : "Jane Hathaway",
+ "tag" : "Nancy Kulp",
+ "tagKey" : "50090"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50084,
+ "order" : 0,
+ "tag" : "Paul Henning",
+ "tagKey" : "50084"
+ }
+ ],
+ "art" : "/mock/art/studio/2104/backdrop.jpg",
+ "duration" : 1545000,
+ "grandparentRatingKey" : "2104",
+ "grandparentThumb" : "/mock/art/studio/2104/poster.png",
+ "grandparentTitle" : "The Beverly Hillbillies",
+ "index" : 2,
+ "key" : "/library/metadata/2207",
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1962-10-03",
+ "parentIndex" : 1,
+ "parentRatingKey" : "2305",
+ "parentTitle" : "Season 1",
+ "ratingKey" : "2207",
+ "studio" : "Filmways Television",
+ "summary" : "The Clampetts struggle with the unfamiliar comforts of their mansion, while Miss Hathaway mistakes the new owners for household servants.",
+ "title" : "Getting Settled",
+ "type" : "episode",
+ "viewCount" : 0,
+ "year" : 1962
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0786437/",
+ "https://watch.plex.tv/show/the-beverly-hillbillies/season/1/episode/2",
+ "https://www.fesfilms.com/public-domain/television/comedy.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 172800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "art" : "/mock/art/studio/2104/backdrop.jpg",
+ "childCount" : 1,
+ "index" : 2,
+ "key" : "/library/metadata/2306/children",
+ "leafCount" : 1,
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "parentRatingKey" : "2104",
+ "parentThumb" : "/mock/art/studio/2104/poster.png",
+ "parentTitle" : "The Beverly Hillbillies",
+ "ratingKey" : "2306",
+ "title" : "Season 2",
+ "type" : "season",
+ "viewedLeafCount" : 0,
+ "year" : 1963
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0055662/episodes/?season=2",
+ "https://www.fesfilms.com/public-domain/television/comedy.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 172800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Director" : [
+ {
+ "id" : 50082,
+ "order" : 0,
+ "tag" : "Richard Whorf",
+ "tagKey" : "50082"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Comedy"
+ },
+ {
+ "tag" : "Family"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0522511"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7.9
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50085,
+ "order" : 0,
+ "role" : "Jed Clampett",
+ "tag" : "Buddy Ebsen",
+ "tagKey" : "50085"
+ },
+ {
+ "id" : 50086,
+ "order" : 1,
+ "role" : "Granny",
+ "tag" : "Irene Ryan",
+ "tagKey" : "50086"
+ },
+ {
+ "id" : 50087,
+ "order" : 2,
+ "role" : "Elly May Clampett",
+ "tag" : "Donna Douglas",
+ "tagKey" : "50087"
+ },
+ {
+ "id" : 50088,
+ "order" : 3,
+ "role" : "Jethro Bodine",
+ "tag" : "Max Baer Jr.",
+ "tagKey" : "50088"
+ },
+ {
+ "id" : 50089,
+ "order" : 4,
+ "role" : "Milburn Drysdale",
+ "tag" : "Raymond Bailey",
+ "tagKey" : "50089"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50084,
+ "order" : 0,
+ "tag" : "Paul Henning",
+ "tagKey" : "50084"
+ },
+ {
+ "id" : 50092,
+ "order" : 1,
+ "tag" : "Mark Tuttle",
+ "tagKey" : "50092"
+ }
+ ],
+ "art" : "/mock/art/studio/2104/backdrop.jpg",
+ "duration" : 1550000,
+ "grandparentRatingKey" : "2104",
+ "grandparentThumb" : "/mock/art/studio/2104/poster.png",
+ "grandparentTitle" : "The Beverly Hillbillies",
+ "index" : 1,
+ "key" : "/library/metadata/2208",
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1963-09-25",
+ "parentIndex" : 2,
+ "parentRatingKey" : "2306",
+ "parentTitle" : "Season 2",
+ "ratingKey" : "2208",
+ "studio" : "Filmways Television",
+ "summary" : "Jed pretends to be ill to cheer up homesick Granny. Her mountain remedies soon draw Mr. Drysdale and his physician into the deception.",
+ "title" : "Jed Gets the Misery",
+ "type" : "episode",
+ "viewCount" : 0,
+ "year" : 1963
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0522511/",
+ "https://watch.plex.tv/show/the-beverly-hillbillies/season/2/episode/1",
+ "https://www.fesfilms.com/public-domain/television/comedy.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 259200,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Country" : [
+ {
+ "tag" : "United States"
+ }
+ ],
+ "Director" : [
+ {
+ "id" : 50093,
+ "order" : 0,
+ "tag" : "Sheldon Reynolds",
+ "tagKey" : "50093"
+ },
+ {
+ "id" : 50094,
+ "order" : 1,
+ "tag" : "Jack Gage",
+ "tagKey" : "50094"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Crime"
+ },
+ {
+ "tag" : "Drama"
+ },
+ {
+ "tag" : "Mystery"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0046642"
+ }
+ ],
+ "Producer" : [
+ {
+ "id" : 50093,
+ "order" : 0,
+ "tag" : "Sheldon Reynolds",
+ "tagKey" : "50093"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7.5
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50095,
+ "order" : 0,
+ "role" : "Sherlock Holmes",
+ "tag" : "Ronald Howard",
+ "tagKey" : "50095"
+ },
+ {
+ "id" : 50096,
+ "order" : 1,
+ "role" : "Dr. John H. Watson",
+ "tag" : "Howard Marion-Crawford",
+ "tagKey" : "50096"
+ },
+ {
+ "id" : 50097,
+ "order" : 2,
+ "role" : "Inspector Lestrade",
+ "tag" : "Archie Duncan",
+ "tagKey" : "50097"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50093,
+ "order" : 0,
+ "tag" : "Sheldon Reynolds",
+ "tagKey" : "50093"
+ },
+ {
+ "id" : 50058,
+ "order" : 1,
+ "tag" : "Arthur Conan Doyle",
+ "tagKey" : "50058"
+ }
+ ],
+ "art" : "/mock/art/studio/2105/backdrop.jpg",
+ "childCount" : 1,
+ "contentRating" : "TV-PG",
+ "key" : "/library/metadata/2105/children",
+ "leafCount" : 3,
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1954-10-18",
+ "ratingKey" : "2105",
+ "studio" : "Guild Films",
+ "summary" : "From their rooms at Baker Street, Sherlock Holmes and Dr. Watson investigate crimes that leave Scotland Yard baffled, testing their deductions against deception, superstition, and elaborate criminal schemes.",
+ "thumb" : "/mock/art/studio/2105/poster.png",
+ "title" : "Sherlock Holmes",
+ "titleSort" : "Sherlock Holmes",
+ "type" : "show",
+ "viewedLeafCount" : 0,
+ "year" : 1954
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://sherlock-holmes.org.uk/conan-doyle/television/",
+ "https://www.imdb.com/title/tt0046642/",
+ "https://www.fesfilms.com/public-domain/television/sh.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 259200,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "art" : "/mock/art/studio/2105/backdrop.jpg",
+ "childCount" : 3,
+ "index" : 1,
+ "key" : "/library/metadata/2307/children",
+ "leafCount" : 3,
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "parentRatingKey" : "2105",
+ "parentThumb" : "/mock/art/studio/2105/poster.png",
+ "parentTitle" : "Sherlock Holmes",
+ "ratingKey" : "2307",
+ "title" : "Season 1",
+ "type" : "season",
+ "viewedLeafCount" : 0,
+ "year" : 1954
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0046642/episodes/?season=1",
+ "https://www.fesfilms.com/public-domain/television/sh.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 259200,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Director" : [
+ {
+ "id" : 50094,
+ "order" : 0,
+ "tag" : "Jack Gage",
+ "tagKey" : "50094"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Crime"
+ },
+ {
+ "tag" : "Drama"
+ },
+ {
+ "tag" : "Mystery"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0699412"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 6.8
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50095,
+ "order" : 0,
+ "role" : "Sherlock Holmes",
+ "tag" : "Ronald Howard",
+ "tagKey" : "50095"
+ },
+ {
+ "id" : 50096,
+ "order" : 1,
+ "role" : "Dr. John H. Watson",
+ "tag" : "Howard Marion-Crawford",
+ "tagKey" : "50096"
+ },
+ {
+ "id" : 50097,
+ "order" : 2,
+ "role" : "Inspector Lestrade",
+ "tag" : "Archie Duncan",
+ "tagKey" : "50097"
+ },
+ {
+ "id" : 50098,
+ "order" : 3,
+ "role" : "Joan",
+ "tag" : "Ursula Howells",
+ "tagKey" : "50098"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50058,
+ "order" : 0,
+ "tag" : "Arthur Conan Doyle",
+ "tagKey" : "50058"
+ },
+ {
+ "id" : 50093,
+ "order" : 1,
+ "tag" : "Sheldon Reynolds",
+ "tagKey" : "50093"
+ }
+ ],
+ "art" : "/mock/art/studio/2105/backdrop.jpg",
+ "duration" : 1800000,
+ "grandparentRatingKey" : "2105",
+ "grandparentThumb" : "/mock/art/studio/2105/poster.png",
+ "grandparentTitle" : "Sherlock Holmes",
+ "index" : 1,
+ "key" : "/library/metadata/2209",
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1954-10-18",
+ "parentIndex" : 1,
+ "parentRatingKey" : "2307",
+ "parentTitle" : "Season 1",
+ "ratingKey" : "2209",
+ "studio" : "Guild Films",
+ "summary" : "Newly returned to London, Dr. Watson becomes Holmes’s roommate and helps investigate a murder in which the victim’s fiancée appears to be the obvious suspect.",
+ "title" : "The Case of the Cunningham Heritage",
+ "type" : "episode",
+ "viewCount" : 0,
+ "year" : 1954
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0699412/",
+ "https://tv.apple.com/gb/show/sherlock-holmes/umc.cmc.1me1hy6ja5fqm8rraeiyatubp",
+ "https://www.fesfilms.com/public-domain/television/sh.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 259200,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Director" : [
+ {
+ "id" : 50094,
+ "order" : 0,
+ "tag" : "Jack Gage",
+ "tagKey" : "50094"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Crime"
+ },
+ {
+ "tag" : "Drama"
+ },
+ {
+ "tag" : "Mystery"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0699423"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50095,
+ "order" : 0,
+ "role" : "Sherlock Holmes",
+ "tag" : "Ronald Howard",
+ "tagKey" : "50095"
+ },
+ {
+ "id" : 50096,
+ "order" : 1,
+ "role" : "Dr. John H. Watson",
+ "tag" : "Howard Marion-Crawford",
+ "tagKey" : "50096"
+ },
+ {
+ "id" : 50097,
+ "order" : 2,
+ "role" : "Inspector Lestrade",
+ "tag" : "Archie Duncan",
+ "tagKey" : "50097"
+ },
+ {
+ "id" : 50099,
+ "order" : 3,
+ "role" : "Lady Nina Beryl",
+ "tag" : "Paulette Goddard",
+ "tagKey" : "50099"
+ },
+ {
+ "id" : 50100,
+ "order" : 4,
+ "role" : "Lord George Beryl",
+ "tag" : "Peter Copley",
+ "tagKey" : "50100"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50058,
+ "order" : 0,
+ "tag" : "Arthur Conan Doyle",
+ "tagKey" : "50058"
+ },
+ {
+ "id" : 50093,
+ "order" : 1,
+ "tag" : "Sheldon Reynolds",
+ "tagKey" : "50093"
+ }
+ ],
+ "art" : "/mock/art/studio/2105/backdrop.jpg",
+ "duration" : 1560000,
+ "grandparentRatingKey" : "2105",
+ "grandparentThumb" : "/mock/art/studio/2105/poster.png",
+ "grandparentTitle" : "Sherlock Holmes",
+ "index" : 2,
+ "key" : "/library/metadata/2210",
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1954-10-25",
+ "parentIndex" : 1,
+ "parentRatingKey" : "2307",
+ "parentTitle" : "Season 1",
+ "ratingKey" : "2210",
+ "studio" : "Guild Films",
+ "summary" : "Lady Beryl admits to a killing in her home, but Holmes doubts her confession and searches for the person she may be protecting.",
+ "title" : "The Case of Lady Beryl",
+ "type" : "episode",
+ "viewCount" : 0,
+ "year" : 1954
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0699423/",
+ "https://tv.apple.com/gb/show/sherlock-holmes/umc.cmc.1me1hy6ja5fqm8rraeiyatubp",
+ "https://www.fesfilms.com/public-domain/television/sh.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 259200,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Director" : [
+ {
+ "id" : 50093,
+ "order" : 0,
+ "tag" : "Sheldon Reynolds",
+ "tagKey" : "50093"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Crime"
+ },
+ {
+ "tag" : "Drama"
+ },
+ {
+ "tag" : "Mystery"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0699426"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 6.8
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50095,
+ "order" : 0,
+ "role" : "Sherlock Holmes",
+ "tag" : "Ronald Howard",
+ "tagKey" : "50095"
+ },
+ {
+ "id" : 50096,
+ "order" : 1,
+ "role" : "Dr. John H. Watson",
+ "tag" : "Howard Marion-Crawford",
+ "tagKey" : "50096"
+ },
+ {
+ "id" : 50101,
+ "order" : 2,
+ "role" : "Mac Leod",
+ "tag" : "Russell Waters",
+ "tagKey" : "50101"
+ },
+ {
+ "id" : 50102,
+ "order" : 3,
+ "role" : "Morelle",
+ "tag" : "Maurice Teynac",
+ "tagKey" : "50102"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50058,
+ "order" : 0,
+ "tag" : "Arthur Conan Doyle",
+ "tagKey" : "50058"
+ },
+ {
+ "id" : 50093,
+ "order" : 1,
+ "tag" : "Sheldon Reynolds",
+ "tagKey" : "50093"
+ }
+ ],
+ "art" : "/mock/art/studio/2105/backdrop.jpg",
+ "duration" : 1800000,
+ "grandparentRatingKey" : "2105",
+ "grandparentThumb" : "/mock/art/studio/2105/poster.png",
+ "grandparentTitle" : "Sherlock Holmes",
+ "index" : 3,
+ "key" : "/library/metadata/2211",
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1954-11-01",
+ "parentIndex" : 1,
+ "parentRatingKey" : "2307",
+ "parentTitle" : "Season 1",
+ "ratingKey" : "2211",
+ "studio" : "Guild Films",
+ "summary" : "A shooting inside a secluded Sussex castle sends Holmes and Watson searching for an explanation beyond the two apparent suspects.",
+ "title" : "The Case of the Pennsylvania Gun",
+ "type" : "episode",
+ "viewCount" : 0,
+ "year" : 1954
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0699426/",
+ "https://tv.apple.com/gb/show/sherlock-holmes/umc.cmc.1me1hy6ja5fqm8rraeiyatubp",
+ "https://www.fesfilms.com/public-domain/television/sh.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 345600,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Country" : [
+ {
+ "tag" : "United Kingdom"
+ }
+ ],
+ "Director" : [
+ {
+ "id" : 50103,
+ "order" : 0,
+ "tag" : "Terry Bishop",
+ "tagKey" : "50103"
+ },
+ {
+ "id" : 50104,
+ "order" : 1,
+ "tag" : "Bernard Knowles",
+ "tagKey" : "50104"
+ },
+ {
+ "id" : 50105,
+ "order" : 2,
+ "tag" : "Ralph Smart",
+ "tagKey" : "50105"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Action"
+ },
+ {
+ "tag" : "Adventure"
+ },
+ {
+ "tag" : "Family"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0047706"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7.6
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50108,
+ "order" : 0,
+ "role" : "Robin Hood",
+ "tag" : "Richard Greene",
+ "tagKey" : "50108"
+ },
+ {
+ "id" : 50097,
+ "order" : 1,
+ "role" : "Little John",
+ "tag" : "Archie Duncan",
+ "tagKey" : "50097"
+ },
+ {
+ "id" : 50109,
+ "order" : 2,
+ "role" : "Friar Tuck",
+ "tag" : "Alexander Gauge",
+ "tagKey" : "50109"
+ },
+ {
+ "id" : 50110,
+ "order" : 3,
+ "role" : "Sheriff of Nottingham",
+ "tag" : "Alan Wheatley",
+ "tagKey" : "50110"
+ },
+ {
+ "id" : 50111,
+ "order" : 4,
+ "role" : "Maid Marian",
+ "tag" : "Bernadette O'Farrell",
+ "tagKey" : "50111"
+ },
+ {
+ "id" : 50112,
+ "order" : 5,
+ "role" : "Derwent",
+ "tag" : "Victor Woolf",
+ "tagKey" : "50112"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50106,
+ "order" : 0,
+ "tag" : "Ian McLellan Hunter",
+ "tagKey" : "50106"
+ },
+ {
+ "id" : 50107,
+ "order" : 1,
+ "tag" : "Ring Lardner Jr.",
+ "tagKey" : "50107"
+ }
+ ],
+ "art" : "/mock/art/studio/2106/backdrop.jpg",
+ "childCount" : 2,
+ "key" : "/library/metadata/2106/children",
+ "leafCount" : 3,
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1955-09-26",
+ "ratingKey" : "2106",
+ "studio" : "Sapphire Films",
+ "summary" : "Outlawed after returning from the Crusades, Robin Hood gathers allies in Sherwood Forest. Together they defend villagers against corrupt nobles and the Sheriff of Nottingham.",
+ "thumb" : "/mock/art/studio/2106/poster.png",
+ "title" : "The Adventures of Robin Hood",
+ "titleSort" : "Adventures of Robin Hood",
+ "type" : "show",
+ "viewedLeafCount" : 0,
+ "year" : 1955
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0047706/fullcredits/",
+ "https://www.imdb.com/title/tt0047706/",
+ "https://www.fesfilms.com/public-domain/television/robin.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 345600,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "art" : "/mock/art/studio/2106/backdrop.jpg",
+ "childCount" : 2,
+ "index" : 1,
+ "key" : "/library/metadata/2308/children",
+ "leafCount" : 2,
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "parentRatingKey" : "2106",
+ "parentThumb" : "/mock/art/studio/2106/poster.png",
+ "parentTitle" : "The Adventures of Robin Hood",
+ "ratingKey" : "2308",
+ "title" : "Season 1",
+ "type" : "season",
+ "viewedLeafCount" : 0,
+ "year" : 1955
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0047706/episodes/?season=1",
+ "https://www.fesfilms.com/public-domain/television/robin.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 345600,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Director" : [
+ {
+ "id" : 50105,
+ "order" : 0,
+ "tag" : "Ralph Smart",
+ "tagKey" : "50105"
+ },
+ {
+ "id" : 50104,
+ "order" : 1,
+ "tag" : "Bernard Knowles",
+ "tagKey" : "50104"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Action"
+ },
+ {
+ "tag" : "Adventure"
+ },
+ {
+ "tag" : "Family"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0506369"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7.2
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50108,
+ "order" : 0,
+ "role" : "Robin Hood",
+ "tag" : "Richard Greene",
+ "tagKey" : "50108"
+ },
+ {
+ "id" : 50110,
+ "order" : 1,
+ "role" : "Sheriff of Nottingham",
+ "tag" : "Alan Wheatley",
+ "tagKey" : "50110"
+ },
+ {
+ "id" : 50113,
+ "order" : 2,
+ "role" : "Sir Roger de Lisle",
+ "tag" : "Leo McKern",
+ "tagKey" : "50113"
+ },
+ {
+ "id" : 50114,
+ "order" : 3,
+ "role" : "Edgar",
+ "tag" : "Alfie Bass",
+ "tagKey" : "50114"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50106,
+ "order" : 0,
+ "tag" : "Ian McLellan Hunter",
+ "tagKey" : "50106"
+ },
+ {
+ "id" : 50107,
+ "order" : 1,
+ "tag" : "Ring Lardner Jr.",
+ "tagKey" : "50107"
+ }
+ ],
+ "art" : "/mock/art/studio/2106/backdrop.jpg",
+ "duration" : 1500000,
+ "grandparentRatingKey" : "2106",
+ "grandparentThumb" : "/mock/art/studio/2106/poster.png",
+ "grandparentTitle" : "The Adventures of Robin Hood",
+ "index" : 1,
+ "key" : "/library/metadata/2212",
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1955-09-26",
+ "parentIndex" : 1,
+ "parentRatingKey" : "2308",
+ "parentTitle" : "Season 1",
+ "ratingKey" : "2212",
+ "studio" : "Sapphire Films",
+ "summary" : "Robin returns from the Crusades to find another man occupying his estate. Branded an outlaw, he takes refuge in Sherwood Forest.",
+ "title" : "The Coming of Robin Hood",
+ "type" : "episode",
+ "viewCount" : 0,
+ "year" : 1955
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0506369/",
+ "https://www.fesfilms.com/public-domain/television/robin.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 345600,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Director" : [
+ {
+ "id" : 50105,
+ "order" : 0,
+ "tag" : "Ralph Smart",
+ "tagKey" : "50105"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Action"
+ },
+ {
+ "tag" : "Adventure"
+ },
+ {
+ "tag" : "Family"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0506408"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7.2
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50108,
+ "order" : 0,
+ "role" : "Robin Hood",
+ "tag" : "Richard Greene",
+ "tagKey" : "50108"
+ },
+ {
+ "id" : 50110,
+ "order" : 1,
+ "role" : "Sheriff of Nottingham",
+ "tag" : "Alan Wheatley",
+ "tagKey" : "50110"
+ },
+ {
+ "id" : 50113,
+ "order" : 2,
+ "role" : "Herbert of Doncaster",
+ "tag" : "Leo McKern",
+ "tagKey" : "50113"
+ },
+ {
+ "id" : 50114,
+ "order" : 3,
+ "role" : "Edgar",
+ "tag" : "Alfie Bass",
+ "tagKey" : "50114"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50106,
+ "order" : 0,
+ "tag" : "Ian McLellan Hunter",
+ "tagKey" : "50106"
+ },
+ {
+ "id" : 50107,
+ "order" : 1,
+ "tag" : "Ring Lardner Jr.",
+ "tagKey" : "50107"
+ }
+ ],
+ "art" : "/mock/art/studio/2106/backdrop.jpg",
+ "duration" : 1500000,
+ "grandparentRatingKey" : "2106",
+ "grandparentThumb" : "/mock/art/studio/2106/poster.png",
+ "grandparentTitle" : "The Adventures of Robin Hood",
+ "index" : 2,
+ "key" : "/library/metadata/2213",
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1955-10-03",
+ "parentIndex" : 1,
+ "parentRatingKey" : "2308",
+ "parentTitle" : "Season 1",
+ "ratingKey" : "2213",
+ "studio" : "Sapphire Films",
+ "summary" : "Robin takes money from an exploitative lender and returns it to struggling villagers, bringing him into conflict with both the Sheriff and his fellow outlaws.",
+ "title" : "The Moneylender",
+ "type" : "episode",
+ "viewCount" : 0,
+ "year" : 1955
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0506408/",
+ "https://www.fesfilms.com/public-domain/television/robin.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 345600,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "art" : "/mock/art/studio/2106/backdrop.jpg",
+ "childCount" : 1,
+ "index" : 2,
+ "key" : "/library/metadata/2309/children",
+ "leafCount" : 1,
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "parentRatingKey" : "2106",
+ "parentThumb" : "/mock/art/studio/2106/poster.png",
+ "parentTitle" : "The Adventures of Robin Hood",
+ "ratingKey" : "2309",
+ "title" : "Season 2",
+ "type" : "season",
+ "viewedLeafCount" : 0,
+ "year" : 1956
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0047706/episodes/?season=2",
+ "https://www.fesfilms.com/public-domain/television/robin.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 345600,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Director" : [
+ {
+ "id" : 50104,
+ "order" : 0,
+ "tag" : "Bernard Knowles",
+ "tagKey" : "50104"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Action"
+ },
+ {
+ "tag" : "Adventure"
+ },
+ {
+ "tag" : "Family"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0506310"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50108,
+ "order" : 0,
+ "role" : "Robin Hood",
+ "tag" : "Richard Greene",
+ "tagKey" : "50108"
+ },
+ {
+ "id" : 50097,
+ "order" : 1,
+ "role" : "Little John",
+ "tag" : "Archie Duncan",
+ "tagKey" : "50097"
+ },
+ {
+ "id" : 50109,
+ "order" : 2,
+ "role" : "Friar Tuck",
+ "tag" : "Alexander Gauge",
+ "tagKey" : "50109"
+ },
+ {
+ "id" : 50111,
+ "order" : 3,
+ "role" : "Maid Marian",
+ "tag" : "Bernadette O'Farrell",
+ "tagKey" : "50111"
+ },
+ {
+ "id" : 50116,
+ "order" : 4,
+ "role" : "Wat Longfellow",
+ "tag" : "Leslie Phillips",
+ "tagKey" : "50116"
+ },
+ {
+ "id" : 50117,
+ "order" : 5,
+ "role" : "Widow Winifred",
+ "tag" : "Betty Impey",
+ "tagKey" : "50117"
+ },
+ {
+ "id" : 50118,
+ "order" : 6,
+ "role" : "Bailiff Baldwin",
+ "tag" : "Donald Pleasence",
+ "tagKey" : "50118"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50115,
+ "order" : 0,
+ "tag" : "Neil R. Collins",
+ "tagKey" : "50115"
+ }
+ ],
+ "art" : "/mock/art/studio/2106/backdrop.jpg",
+ "duration" : 1500000,
+ "grandparentRatingKey" : "2106",
+ "grandparentThumb" : "/mock/art/studio/2106/poster.png",
+ "grandparentTitle" : "The Adventures of Robin Hood",
+ "index" : 1,
+ "key" : "/library/metadata/2214",
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1956-10-01",
+ "parentIndex" : 2,
+ "parentRatingKey" : "2309",
+ "parentTitle" : "Season 2",
+ "ratingKey" : "2214",
+ "studio" : "Sapphire Films",
+ "summary" : "Robin helps Wat Longfellow escape servitude so he can marry the widow Winifred, despite the obstacles placed in their way by a local bailiff.",
+ "title" : "A Village Wooing",
+ "type" : "episode",
+ "viewCount" : 0,
+ "year" : 1956
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0506310/",
+ "https://thetvdb.com/series/the-adventures-of-robin-hood/episodes/12811",
+ "https://www.fesfilms.com/public-domain/television/robin.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 432000,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Country" : [
+ {
+ "tag" : "United States"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Action"
+ },
+ {
+ "tag" : "Adventure"
+ },
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0140738"
+ }
+ ],
+ "Producer" : [
+ {
+ "id" : 50120,
+ "order" : 0,
+ "tag" : "Wenzel Lüdecke",
+ "tagKey" : "50120"
+ },
+ {
+ "id" : 50121,
+ "order" : 1,
+ "tag" : "Edward Gruskin",
+ "tagKey" : "50121"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 5.6
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50122,
+ "order" : 0,
+ "role" : "Flash Gordon",
+ "tag" : "Steve Holland",
+ "tagKey" : "50122"
+ },
+ {
+ "id" : 50123,
+ "order" : 1,
+ "role" : "Dale Arden",
+ "tag" : "Irene Champlin",
+ "tagKey" : "50123"
+ },
+ {
+ "id" : 50124,
+ "order" : 2,
+ "role" : "Dr. Hans Zarkov",
+ "tag" : "Joseph Nash",
+ "tagKey" : "50124"
+ },
+ {
+ "id" : 50125,
+ "order" : 3,
+ "role" : "Commander Paul Richards",
+ "tag" : "Henry Beckman",
+ "tagKey" : "50125"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50119,
+ "order" : 0,
+ "tag" : "Alex Raymond",
+ "tagKey" : "50119"
+ }
+ ],
+ "art" : "/mock/art/studio/2107/backdrop.jpg",
+ "childCount" : 1,
+ "key" : "/library/metadata/2107/children",
+ "leafCount" : 3,
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1954-10-01",
+ "ratingKey" : "2107",
+ "studio" : "Inter-Continental Film Productions",
+ "summary" : "Flash Gordon, Dale Arden, and Dr. Zarkov travel between planets for the Galaxy Bureau of Investigation, confronting dangerous inventions, ruthless rulers, and threats to interplanetary peace.",
+ "thumb" : "/mock/art/studio/2107/poster.png",
+ "title" : "Flash Gordon",
+ "titleSort" : "Flash Gordon",
+ "type" : "show",
+ "viewedLeafCount" : 0,
+ "year" : 1954
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0140738/fullcredits/",
+ "https://thetvdb.com/series/flash-gordon-1954/allseasons/official",
+ "https://www.imdb.com/title/tt0140738/",
+ "https://www.fesfilms.com/public-domain/television/flash.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 432000,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "art" : "/mock/art/studio/2107/backdrop.jpg",
+ "childCount" : 3,
+ "index" : 1,
+ "key" : "/library/metadata/2310/children",
+ "leafCount" : 3,
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "parentRatingKey" : "2107",
+ "parentThumb" : "/mock/art/studio/2107/poster.png",
+ "parentTitle" : "Flash Gordon",
+ "ratingKey" : "2310",
+ "title" : "Season 1",
+ "type" : "season",
+ "viewedLeafCount" : 0,
+ "year" : 1954
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0140738/episodes/?season=1",
+ "https://www.fesfilms.com/public-domain/television/flash.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 432000,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Director" : [
+ {
+ "id" : 50126,
+ "order" : 0,
+ "tag" : "Gunther von Fritsch",
+ "tagKey" : "50126"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Action"
+ },
+ {
+ "tag" : "Adventure"
+ },
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt1024158"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 5.8
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50122,
+ "order" : 0,
+ "role" : "Flash Gordon",
+ "tag" : "Steve Holland",
+ "tagKey" : "50122"
+ },
+ {
+ "id" : 50123,
+ "order" : 1,
+ "role" : "Dale Arden",
+ "tag" : "Irene Champlin",
+ "tagKey" : "50123"
+ },
+ {
+ "id" : 50124,
+ "order" : 2,
+ "role" : "Dr. Hans Zarkov",
+ "tag" : "Joseph Nash",
+ "tagKey" : "50124"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50127,
+ "order" : 0,
+ "tag" : "Earl Markham",
+ "tagKey" : "50127"
+ },
+ {
+ "id" : 50128,
+ "order" : 1,
+ "tag" : "Bruce Elliot",
+ "tagKey" : "50128"
+ },
+ {
+ "id" : 50119,
+ "order" : 2,
+ "tag" : "Alex Raymond",
+ "tagKey" : "50119"
+ }
+ ],
+ "art" : "/mock/art/studio/2107/backdrop.jpg",
+ "duration" : 1800000,
+ "grandparentRatingKey" : "2107",
+ "grandparentThumb" : "/mock/art/studio/2107/poster.png",
+ "grandparentTitle" : "Flash Gordon",
+ "index" : 1,
+ "key" : "/library/metadata/2215",
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1954-10-01",
+ "parentIndex" : 1,
+ "parentRatingKey" : "2310",
+ "parentTitle" : "Season 1",
+ "ratingKey" : "2215",
+ "studio" : "Inter-Continental Film Productions",
+ "summary" : "Flash, Dale, and Zarkov investigate deaths on a distant planet after a surviving explorer claims his expedition was destroyed by an ancient curse.",
+ "title" : "Flash Gordon and the Planet of Death",
+ "type" : "episode",
+ "viewCount" : 0,
+ "year" : 1954
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt1024158/",
+ "https://thetvdb.com/series/flash-gordon-1954/episodes/1238",
+ "https://www.fesfilms.com/public-domain/television/flash.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 432000,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action"
+ },
+ {
+ "tag" : "Adventure"
+ },
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "tvdb://1242"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 5
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50122,
+ "order" : 0,
+ "role" : "Flash Gordon",
+ "tag" : "Steve Holland",
+ "tagKey" : "50122"
+ },
+ {
+ "id" : 50123,
+ "order" : 1,
+ "role" : "Dale Arden",
+ "tag" : "Irene Champlin",
+ "tagKey" : "50123"
+ },
+ {
+ "id" : 50124,
+ "order" : 2,
+ "role" : "Dr. Hans Zarkov",
+ "tag" : "Joseph Nash",
+ "tagKey" : "50124"
+ }
+ ],
+ "art" : "/mock/art/studio/2107/backdrop.jpg",
+ "duration" : 1800000,
+ "grandparentRatingKey" : "2107",
+ "grandparentThumb" : "/mock/art/studio/2107/poster.png",
+ "grandparentTitle" : "Flash Gordon",
+ "index" : 5,
+ "key" : "/library/metadata/2216",
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1954-11-05",
+ "parentIndex" : 1,
+ "parentRatingKey" : "2310",
+ "parentTitle" : "Season 1",
+ "ratingKey" : "2216",
+ "studio" : "Inter-Continental Film Productions",
+ "summary" : "A friend sent to investigate Akim’s brutal regime returns determined to kill Flash. Flash and Dale travel to the planet to uncover what happened.",
+ "title" : "Akim the Terrible",
+ "type" : "episode",
+ "viewCount" : 0,
+ "year" : 1954
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://thetvdb.com/series/flash-gordon-1954/episodes/1242",
+ "https://www.imdb.com/title/tt0140738/episodes/?year=1954",
+ "https://www.fesfilms.com/public-domain/television/flash.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 432000,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action"
+ },
+ {
+ "tag" : "Adventure"
+ },
+ {
+ "tag" : "Science Fiction"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "tvdb://1243"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 5.4
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50122,
+ "order" : 0,
+ "role" : "Flash Gordon",
+ "tag" : "Steve Holland",
+ "tagKey" : "50122"
+ },
+ {
+ "id" : 50123,
+ "order" : 1,
+ "role" : "Dale Arden",
+ "tag" : "Irene Champlin",
+ "tagKey" : "50123"
+ }
+ ],
+ "art" : "/mock/art/studio/2107/backdrop.jpg",
+ "duration" : 1800000,
+ "grandparentRatingKey" : "2107",
+ "grandparentThumb" : "/mock/art/studio/2107/poster.png",
+ "grandparentTitle" : "Flash Gordon",
+ "index" : 6,
+ "key" : "/library/metadata/2217",
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1954-11-12",
+ "parentIndex" : 1,
+ "parentRatingKey" : "2310",
+ "parentTitle" : "Season 1",
+ "ratingKey" : "2217",
+ "studio" : "Inter-Continental Film Productions",
+ "summary" : "Flash and Dale come to the aid of a prospector and his daughter when space pirates try to seize his valuable mineral discovery.",
+ "title" : "The Claim Jumpers",
+ "type" : "episode",
+ "viewCount" : 0,
+ "year" : 1954
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://thetvdb.com/series/flash-gordon-1954/episodes/1243",
+ "https://www.imdb.com/title/tt0140738/episodes/?year=1954",
+ "https://www.fesfilms.com/public-domain/television/flash.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 518400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Country" : [
+ {
+ "tag" : "United States"
+ }
+ ],
+ "Director" : [
+ {
+ "id" : 50129,
+ "order" : 0,
+ "tag" : "Maury Thompson",
+ "tagKey" : "50129"
+ },
+ {
+ "id" : 50130,
+ "order" : 1,
+ "tag" : "Jack Donohue",
+ "tagKey" : "50130"
+ },
+ {
+ "id" : 50131,
+ "order" : 2,
+ "tag" : "Desi Arnaz",
+ "tagKey" : "50131"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Comedy"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0055686"
+ }
+ ],
+ "Producer" : [
+ {
+ "id" : 50136,
+ "order" : 0,
+ "tag" : "Elliott Lewis",
+ "tagKey" : "50136"
+ },
+ {
+ "id" : 50137,
+ "order" : 1,
+ "tag" : "Tommy Thompson",
+ "tagKey" : "50137"
+ },
+ {
+ "id" : 50131,
+ "order" : 2,
+ "tag" : "Desi Arnaz",
+ "tagKey" : "50131"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7.2
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50138,
+ "order" : 0,
+ "role" : "Lucy Carmichael",
+ "tag" : "Lucille Ball",
+ "tagKey" : "50138"
+ },
+ {
+ "id" : 50139,
+ "order" : 1,
+ "role" : "Theodore J. Mooney",
+ "tag" : "Gale Gordon",
+ "tagKey" : "50139"
+ },
+ {
+ "id" : 50140,
+ "order" : 2,
+ "role" : "Vivian Bagley",
+ "tag" : "Vivian Vance",
+ "tagKey" : "50140"
+ },
+ {
+ "id" : 50141,
+ "order" : 3,
+ "role" : "Mary Jane Lewis",
+ "tag" : "Mary Jane Croft",
+ "tagKey" : "50141"
+ },
+ {
+ "id" : 50142,
+ "order" : 4,
+ "role" : "Mr. Cheever",
+ "tag" : "Roy Roberts",
+ "tagKey" : "50142"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50132,
+ "order" : 0,
+ "tag" : "Bob Carroll Jr.",
+ "tagKey" : "50132"
+ },
+ {
+ "id" : 50133,
+ "order" : 1,
+ "tag" : "Madelyn Davis",
+ "tagKey" : "50133"
+ },
+ {
+ "id" : 50134,
+ "order" : 2,
+ "tag" : "Bob Schiller",
+ "tagKey" : "50134"
+ },
+ {
+ "id" : 50135,
+ "order" : 3,
+ "tag" : "Bob Weiskopf",
+ "tagKey" : "50135"
+ }
+ ],
+ "art" : "/mock/art/studio/2108/backdrop.jpg",
+ "childCount" : 2,
+ "contentRating" : "TV-PG",
+ "key" : "/library/metadata/2108/children",
+ "leafCount" : 3,
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1962-10-01",
+ "ratingKey" : "2108",
+ "studio" : "Desilu Productions",
+ "summary" : "Widow Lucy Carmichael tackles family life and a succession of ambitious schemes. Her adventures eventually take her to California, where her work at a bank repeatedly exasperates Mr. Mooney.",
+ "thumb" : "/mock/art/studio/2108/poster.png",
+ "title" : "The Lucy Show",
+ "titleSort" : "Lucy Show",
+ "type" : "show",
+ "viewedLeafCount" : 0,
+ "year" : 1962
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://watch.plex.tv/en-GB/show/the-lucy-show",
+ "https://pro.imdb.com/title/tt0055686/",
+ "https://www.imdb.com/title/tt0055686/",
+ "https://www.fesfilms.com/public-domain/television/lucy.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 518400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "art" : "/mock/art/studio/2108/backdrop.jpg",
+ "childCount" : 2,
+ "index" : 5,
+ "key" : "/library/metadata/2311/children",
+ "leafCount" : 2,
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "parentRatingKey" : "2108",
+ "parentThumb" : "/mock/art/studio/2108/poster.png",
+ "parentTitle" : "The Lucy Show",
+ "ratingKey" : "2311",
+ "title" : "Season 5",
+ "type" : "season",
+ "viewedLeafCount" : 0,
+ "year" : 1966
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0055686/episodes/?season=5",
+ "https://www.fesfilms.com/public-domain/television/lucy.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 518400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Director" : [
+ {
+ "id" : 50129,
+ "order" : 0,
+ "tag" : "Maury Thompson",
+ "tagKey" : "50129"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Comedy"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0637510"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7.3
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50138,
+ "order" : 0,
+ "role" : "Lucy Carmichael",
+ "tag" : "Lucille Ball",
+ "tagKey" : "50138"
+ },
+ {
+ "id" : 50139,
+ "order" : 1,
+ "role" : "Theodore J. Mooney",
+ "tag" : "Gale Gordon",
+ "tagKey" : "50139"
+ },
+ {
+ "id" : 50145,
+ "order" : 2,
+ "role" : "George Burns",
+ "tag" : "George Burns",
+ "tagKey" : "50145"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50143,
+ "order" : 0,
+ "tag" : "Robert O'Brien",
+ "tagKey" : "50143"
+ },
+ {
+ "id" : 50144,
+ "order" : 1,
+ "tag" : "Irene Kampen",
+ "tagKey" : "50144"
+ }
+ ],
+ "art" : "/mock/art/studio/2108/backdrop.jpg",
+ "duration" : 1525000,
+ "grandparentRatingKey" : "2108",
+ "grandparentThumb" : "/mock/art/studio/2108/poster.png",
+ "grandparentTitle" : "The Lucy Show",
+ "index" : 1,
+ "key" : "/library/metadata/2218",
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1966-09-12",
+ "parentIndex" : 5,
+ "parentRatingKey" : "2311",
+ "parentTitle" : "Season 5",
+ "ratingKey" : "2218",
+ "studio" : "Desilu Productions",
+ "summary" : "George Burns recognizes Lucy’s comic talent during a visit to the bank and recruits her for his nightclub act, with Mr. Mooney eager to help her leave.",
+ "title" : "Lucy with George Burns",
+ "type" : "episode",
+ "viewCount" : 0,
+ "year" : 1966
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0637510/",
+ "https://watch.plex.tv/show/the-lucy-show/season/5/episode/1",
+ "https://www.fesfilms.com/public-domain/television/lucy.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 518400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Director" : [
+ {
+ "id" : 50129,
+ "order" : 0,
+ "tag" : "Maury Thompson",
+ "tagKey" : "50129"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Comedy"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0637494"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 6.5
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50138,
+ "order" : 0,
+ "role" : "Lucy Carmichael",
+ "tag" : "Lucille Ball",
+ "tagKey" : "50138"
+ },
+ {
+ "id" : 50139,
+ "order" : 1,
+ "role" : "Theodore J. Mooney",
+ "tag" : "Gale Gordon",
+ "tagKey" : "50139"
+ },
+ {
+ "id" : 50142,
+ "order" : 2,
+ "role" : "Admiral",
+ "tag" : "Roy Roberts",
+ "tagKey" : "50142"
+ },
+ {
+ "id" : 50148,
+ "order" : 3,
+ "role" : "Commander Bill Moore",
+ "tag" : "Robert Carson",
+ "tagKey" : "50148"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50146,
+ "order" : 0,
+ "tag" : "Dick Bensfield",
+ "tagKey" : "50146"
+ },
+ {
+ "id" : 50147,
+ "order" : 1,
+ "tag" : "Perry Grant",
+ "tagKey" : "50147"
+ },
+ {
+ "id" : 50143,
+ "order" : 2,
+ "tag" : "Robert O'Brien",
+ "tagKey" : "50143"
+ }
+ ],
+ "art" : "/mock/art/studio/2108/backdrop.jpg",
+ "duration" : 1520000,
+ "grandparentRatingKey" : "2108",
+ "grandparentThumb" : "/mock/art/studio/2108/poster.png",
+ "grandparentTitle" : "The Lucy Show",
+ "index" : 2,
+ "key" : "/library/metadata/2219",
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1966-09-19",
+ "parentIndex" : 5,
+ "parentRatingKey" : "2311",
+ "parentTitle" : "Season 5",
+ "ratingKey" : "2219",
+ "studio" : "Desilu Productions",
+ "summary" : "Lucy boards Mr. Mooney’s submarine to obtain his signature on bank paperwork. When it sails before she can leave, she must pass herself off as a sailor.",
+ "title" : "Lucy and the Submarine",
+ "type" : "episode",
+ "viewCount" : 0,
+ "year" : 1966
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0637494/",
+ "https://watch.plex.tv/show/the-lucy-show/season/5/episode/2",
+ "https://www.fesfilms.com/public-domain/television/lucy.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 518400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "art" : "/mock/art/studio/2108/backdrop.jpg",
+ "childCount" : 1,
+ "index" : 6,
+ "key" : "/library/metadata/2312/children",
+ "leafCount" : 1,
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "parentRatingKey" : "2108",
+ "parentThumb" : "/mock/art/studio/2108/poster.png",
+ "parentTitle" : "The Lucy Show",
+ "ratingKey" : "2312",
+ "title" : "Season 6",
+ "type" : "season",
+ "viewedLeafCount" : 0,
+ "year" : 1967
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0055686/episodes/?season=6",
+ "https://www.fesfilms.com/public-domain/television/lucy.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 518400,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Director" : [
+ {
+ "id" : 50130,
+ "order" : 0,
+ "tag" : "Jack Donohue",
+ "tagKey" : "50130"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Comedy"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0637413"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7.4
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50138,
+ "order" : 0,
+ "role" : "Lucy Carmichael",
+ "tag" : "Lucille Ball",
+ "tagKey" : "50138"
+ },
+ {
+ "id" : 50139,
+ "order" : 1,
+ "role" : "Theodore J. Mooney",
+ "tag" : "Gale Gordon",
+ "tagKey" : "50139"
+ },
+ {
+ "id" : 50142,
+ "order" : 2,
+ "role" : "Mr. Cheever",
+ "tag" : "Roy Roberts",
+ "tagKey" : "50142"
+ },
+ {
+ "id" : 50151,
+ "order" : 3,
+ "role" : "Jack Benny",
+ "tag" : "Jack Benny",
+ "tagKey" : "50151"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50149,
+ "order" : 0,
+ "tag" : "Milt Josefsberg",
+ "tagKey" : "50149"
+ },
+ {
+ "id" : 50150,
+ "order" : 1,
+ "tag" : "Ray Singer",
+ "tagKey" : "50150"
+ }
+ ],
+ "art" : "/mock/art/studio/2108/backdrop.jpg",
+ "duration" : 1517000,
+ "grandparentRatingKey" : "2108",
+ "grandparentThumb" : "/mock/art/studio/2108/poster.png",
+ "grandparentTitle" : "The Lucy Show",
+ "index" : 6,
+ "key" : "/library/metadata/2220",
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1967-10-16",
+ "parentIndex" : 6,
+ "parentRatingKey" : "2312",
+ "parentTitle" : "Season 6",
+ "ratingKey" : "2220",
+ "studio" : "Desilu Productions",
+ "summary" : "Lucy hopes to win Jack Benny’s business for the bank by constructing a vault with security measures even more elaborate than those protecting his own fortune.",
+ "title" : "Lucy Gets Jack Benny's Account",
+ "type" : "episode",
+ "viewCount" : 0,
+ "year" : 1967
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0637413/",
+ "https://watch.plex.tv/show/the-lucy-show/season/6/episode/6",
+ "https://everythinglucy.youns.com/the-lucy-show/the-lucy-show-episode-138.html",
+ "https://www.fesfilms.com/public-domain/television/lucy.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 604800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Country" : [
+ {
+ "tag" : "United States"
+ }
+ ],
+ "Director" : [
+ {
+ "id" : 50241,
+ "order" : 0,
+ "tag" : "John Rich",
+ "tagKey" : "50241"
+ },
+ {
+ "id" : 50242,
+ "order" : 1,
+ "tag" : "Jerry Paris",
+ "tagKey" : "50242"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Comedy"
+ },
+ {
+ "tag" : "Family"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0054533"
+ }
+ ],
+ "Producer" : [
+ {
+ "id" : 50243,
+ "order" : 0,
+ "tag" : "Carl Reiner",
+ "tagKey" : "50243"
+ },
+ {
+ "id" : 50244,
+ "order" : 1,
+ "tag" : "Sheldon Leonard",
+ "tagKey" : "50244"
+ },
+ {
+ "id" : 50245,
+ "order" : 2,
+ "tag" : "Danny Thomas",
+ "tagKey" : "50245"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 8.5
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50246,
+ "order" : 0,
+ "role" : "Rob Petrie",
+ "tag" : "Dick Van Dyke",
+ "tagKey" : "50246"
+ },
+ {
+ "id" : 50247,
+ "order" : 1,
+ "role" : "Laura Petrie",
+ "tag" : "Mary Tyler Moore",
+ "tagKey" : "50247"
+ },
+ {
+ "id" : 50248,
+ "order" : 2,
+ "role" : "Sally Rogers",
+ "tag" : "Rose Marie",
+ "tagKey" : "50248"
+ },
+ {
+ "id" : 50249,
+ "order" : 3,
+ "role" : "Buddy Sorrell",
+ "tag" : "Morey Amsterdam",
+ "tagKey" : "50249"
+ },
+ {
+ "id" : 50250,
+ "order" : 4,
+ "role" : "Ritchie Petrie",
+ "tag" : "Larry Mathews",
+ "tagKey" : "50250"
+ },
+ {
+ "id" : 50251,
+ "order" : 5,
+ "role" : "Mel Cooley",
+ "tag" : "Richard Deacon",
+ "tagKey" : "50251"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50243,
+ "order" : 0,
+ "tag" : "Carl Reiner",
+ "tagKey" : "50243"
+ }
+ ],
+ "art" : "/mock/art/studio/2109/backdrop.jpg",
+ "childCount" : 1,
+ "contentRating" : "TV-PG",
+ "key" : "/library/metadata/2109/children",
+ "leafCount" : 3,
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1961-10-03",
+ "ratingKey" : "2109",
+ "studio" : "Calvada Productions",
+ "summary" : "Comedy writer Rob Petrie juggles the demands of a television variety show with family life in New Rochelle. His colleagues, wife Laura, and son Ritchie turn everyday misunderstandings into comic complications.",
+ "thumb" : "/mock/art/studio/2109/poster.png",
+ "title" : "The Dick Van Dyke Show",
+ "titleSort" : "Dick Van Dyke Show",
+ "type" : "show",
+ "viewedLeafCount" : 0,
+ "year" : 1961
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://watch.plex.tv/show/the-dick-van-dyke-show",
+ "https://www.imdb.com/title/tt0054533/",
+ "https://www.fesfilms.com/public-domain/television/morecomedy.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 604800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "art" : "/mock/art/studio/2109/backdrop.jpg",
+ "childCount" : 3,
+ "index" : 2,
+ "key" : "/library/metadata/2313/children",
+ "leafCount" : 3,
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "parentRatingKey" : "2109",
+ "parentThumb" : "/mock/art/studio/2109/poster.png",
+ "parentTitle" : "The Dick Van Dyke Show",
+ "ratingKey" : "2313",
+ "title" : "Season 2",
+ "type" : "season",
+ "viewedLeafCount" : 0,
+ "year" : 1962
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0054533/episodes/?season=2",
+ "https://www.fesfilms.com/public-domain/television/morecomedy.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 604800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Director" : [
+ {
+ "id" : 50241,
+ "order" : 0,
+ "tag" : "John Rich",
+ "tagKey" : "50241"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Comedy"
+ },
+ {
+ "tag" : "Family"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0559787"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7.4
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50246,
+ "order" : 0,
+ "role" : "Rob Petrie",
+ "tag" : "Dick Van Dyke",
+ "tagKey" : "50246"
+ },
+ {
+ "id" : 50247,
+ "order" : 1,
+ "role" : "Laura Petrie",
+ "tag" : "Mary Tyler Moore",
+ "tagKey" : "50247"
+ },
+ {
+ "id" : 50248,
+ "order" : 2,
+ "role" : "Sally Rogers",
+ "tag" : "Rose Marie",
+ "tagKey" : "50248"
+ },
+ {
+ "id" : 50249,
+ "order" : 3,
+ "role" : "Buddy Sorrell",
+ "tag" : "Morey Amsterdam",
+ "tagKey" : "50249"
+ },
+ {
+ "id" : 50250,
+ "order" : 4,
+ "role" : "Ritchie Petrie",
+ "tag" : "Larry Mathews",
+ "tagKey" : "50250"
+ },
+ {
+ "id" : 50251,
+ "order" : 5,
+ "role" : "Mel Cooley",
+ "tag" : "Richard Deacon",
+ "tagKey" : "50251"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50243,
+ "order" : 0,
+ "tag" : "Carl Reiner",
+ "tagKey" : "50243"
+ }
+ ],
+ "art" : "/mock/art/studio/2109/backdrop.jpg",
+ "duration" : 1500000,
+ "grandparentRatingKey" : "2109",
+ "grandparentThumb" : "/mock/art/studio/2109/poster.png",
+ "grandparentTitle" : "The Dick Van Dyke Show",
+ "index" : 1,
+ "key" : "/library/metadata/2221",
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1962-09-26",
+ "parentIndex" : 2,
+ "parentRatingKey" : "2313",
+ "parentTitle" : "Season 2",
+ "ratingKey" : "2221",
+ "studio" : "Calvada Productions",
+ "summary" : "Rob brings home two ducklings from work, and Ritchie eagerly adopts them. When caring for the birds becomes difficult, Rob must help his son understand what is best for them.",
+ "title" : "Never Name a Duck",
+ "type" : "episode",
+ "viewCount" : 0,
+ "year" : 1962
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0559787/",
+ "https://watch.plex.tv/show/the-dick-van-dyke-show/season/2/episode/1",
+ "https://www.fesfilms.com/public-domain/television/morecomedy.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 604800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Director" : [
+ {
+ "id" : 50241,
+ "order" : 0,
+ "tag" : "John Rich",
+ "tagKey" : "50241"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Comedy"
+ },
+ {
+ "tag" : "Family"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0769914"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7.5
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50246,
+ "order" : 0,
+ "role" : "Rob Petrie",
+ "tag" : "Dick Van Dyke",
+ "tagKey" : "50246"
+ },
+ {
+ "id" : 50247,
+ "order" : 1,
+ "role" : "Laura Petrie",
+ "tag" : "Mary Tyler Moore",
+ "tagKey" : "50247"
+ },
+ {
+ "id" : 50248,
+ "order" : 2,
+ "role" : "Sally Rogers",
+ "tag" : "Rose Marie",
+ "tagKey" : "50248"
+ },
+ {
+ "id" : 50249,
+ "order" : 3,
+ "role" : "Buddy Sorrell",
+ "tag" : "Morey Amsterdam",
+ "tagKey" : "50249"
+ },
+ {
+ "id" : 50250,
+ "order" : 4,
+ "role" : "Ritchie Petrie",
+ "tag" : "Larry Mathews",
+ "tagKey" : "50250"
+ },
+ {
+ "id" : 50251,
+ "order" : 5,
+ "role" : "Mel Cooley",
+ "tag" : "Richard Deacon",
+ "tagKey" : "50251"
+ },
+ {
+ "id" : 50242,
+ "order" : 6,
+ "role" : "Jerry Helper",
+ "tag" : "Jerry Paris",
+ "tagKey" : "50242"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50252,
+ "order" : 0,
+ "tag" : "R. S. Allen",
+ "tagKey" : "50252"
+ },
+ {
+ "id" : 50253,
+ "order" : 1,
+ "tag" : "Harvey Bullock",
+ "tagKey" : "50253"
+ }
+ ],
+ "art" : "/mock/art/studio/2109/backdrop.jpg",
+ "duration" : 1800000,
+ "grandparentRatingKey" : "2109",
+ "grandparentThumb" : "/mock/art/studio/2109/poster.png",
+ "grandparentTitle" : "The Dick Van Dyke Show",
+ "index" : 4,
+ "key" : "/library/metadata/2222",
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1962-10-17",
+ "parentIndex" : 2,
+ "parentRatingKey" : "2313",
+ "parentTitle" : "Season 2",
+ "ratingKey" : "2222",
+ "studio" : "Calvada Productions",
+ "summary" : "Rob discovers that Laura has a bank account he knows nothing about. His curiosity turns into an elaborate guessing game about why she has been saving the money.",
+ "title" : "Bank Book 6565696",
+ "type" : "episode",
+ "viewCount" : 0,
+ "year" : 1962
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0769914/",
+ "https://www.fesfilms.com/public-domain/television/morecomedy.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 604800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Director" : [
+ {
+ "id" : 50241,
+ "order" : 0,
+ "tag" : "John Rich",
+ "tagKey" : "50241"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Comedy"
+ },
+ {
+ "tag" : "Family"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0559763"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7.5
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50246,
+ "order" : 0,
+ "role" : "Rob Petrie",
+ "tag" : "Dick Van Dyke",
+ "tagKey" : "50246"
+ },
+ {
+ "id" : 50247,
+ "order" : 1,
+ "role" : "Laura Petrie",
+ "tag" : "Mary Tyler Moore",
+ "tagKey" : "50247"
+ },
+ {
+ "id" : 50248,
+ "order" : 2,
+ "role" : "Sally Rogers",
+ "tag" : "Rose Marie",
+ "tagKey" : "50248"
+ },
+ {
+ "id" : 50249,
+ "order" : 3,
+ "role" : "Buddy Sorrell",
+ "tag" : "Morey Amsterdam",
+ "tagKey" : "50249"
+ },
+ {
+ "id" : 50251,
+ "order" : 4,
+ "role" : "Mel Cooley",
+ "tag" : "Richard Deacon",
+ "tagKey" : "50251"
+ },
+ {
+ "id" : 50254,
+ "order" : 5,
+ "role" : "Blackie Sorrell",
+ "tag" : "Phil Leeds",
+ "tagKey" : "50254"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50243,
+ "order" : 0,
+ "tag" : "Carl Reiner",
+ "tagKey" : "50243"
+ }
+ ],
+ "art" : "/mock/art/studio/2109/backdrop.jpg",
+ "duration" : 1800000,
+ "grandparentRatingKey" : "2109",
+ "grandparentThumb" : "/mock/art/studio/2109/poster.png",
+ "grandparentTitle" : "The Dick Van Dyke Show",
+ "index" : 5,
+ "key" : "/library/metadata/2223",
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1962-10-24",
+ "parentIndex" : 2,
+ "parentRatingKey" : "2313",
+ "parentTitle" : "Season 2",
+ "ratingKey" : "2223",
+ "studio" : "Calvada Productions",
+ "summary" : "Buddy’s brother Blackie joins the Petries for dinner and a game of pool. Rob mistakes his guest’s apparent lack of skill for an easy opportunity to win.",
+ "title" : "Hustling the Hustler",
+ "type" : "episode",
+ "viewCount" : 0,
+ "year" : 1962
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0559763/",
+ "https://www.fesfilms.com/public-domain/television/morecomedy.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 691200,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Country" : [
+ {
+ "tag" : "United States"
+ }
+ ],
+ "Director" : [
+ {
+ "id" : 50255,
+ "order" : 0,
+ "tag" : "Jack Webb",
+ "tagKey" : "50255"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Crime"
+ },
+ {
+ "tag" : "Drama"
+ },
+ {
+ "tag" : "Mystery"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0043194"
+ }
+ ],
+ "Producer" : [
+ {
+ "id" : 50255,
+ "order" : 0,
+ "tag" : "Jack Webb",
+ "tagKey" : "50255"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7.5
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50255,
+ "order" : 0,
+ "role" : "Sergeant Joe Friday",
+ "tag" : "Jack Webb",
+ "tagKey" : "50255"
+ },
+ {
+ "id" : 50257,
+ "order" : 1,
+ "role" : "Officer Frank Smith",
+ "tag" : "Ben Alexander",
+ "tagKey" : "50257"
+ },
+ {
+ "id" : 50258,
+ "order" : 2,
+ "role" : "Sergeant Ed Jacobs",
+ "tag" : "Barney Phillips",
+ "tagKey" : "50258"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50255,
+ "order" : 0,
+ "tag" : "Jack Webb",
+ "tagKey" : "50255"
+ },
+ {
+ "id" : 50256,
+ "order" : 1,
+ "tag" : "James E. Moser",
+ "tagKey" : "50256"
+ }
+ ],
+ "art" : "/mock/art/studio/2110/backdrop.jpg",
+ "childCount" : 2,
+ "contentRating" : "TV-PG",
+ "key" : "/library/metadata/2110/children",
+ "leafCount" : 3,
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1951-12-16",
+ "ratingKey" : "2110",
+ "studio" : "Mark VII Ltd.",
+ "summary" : "Los Angeles detective Joe Friday and his partners work through criminal cases one interview and piece of evidence at a time. The series follows the routines and pressures of police investigations in a documentary-like style.",
+ "thumb" : "/mock/art/studio/2110/poster.png",
+ "title" : "Dragnet",
+ "titleSort" : "Dragnet",
+ "type" : "show",
+ "viewedLeafCount" : 0,
+ "year" : 1951
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://watch.plex.tv/show/dragnet-1951",
+ "https://www.imdb.com/title/tt0043194/",
+ "https://www.fesfilms.com/public-domain/television/drag.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 691200,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "art" : "/mock/art/studio/2110/backdrop.jpg",
+ "childCount" : 2,
+ "index" : 1,
+ "key" : "/library/metadata/2314/children",
+ "leafCount" : 2,
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "parentRatingKey" : "2110",
+ "parentThumb" : "/mock/art/studio/2110/poster.png",
+ "parentTitle" : "Dragnet",
+ "ratingKey" : "2314",
+ "title" : "Season 1",
+ "type" : "season",
+ "viewedLeafCount" : 0,
+ "year" : 1951
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0043194/episodes/?season=1",
+ "https://www.fesfilms.com/public-domain/television/drag.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 691200,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Director" : [
+ {
+ "id" : 50255,
+ "order" : 0,
+ "tag" : "Jack Webb",
+ "tagKey" : "50255"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Crime"
+ },
+ {
+ "tag" : "Drama"
+ },
+ {
+ "tag" : "Mystery"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0565731"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 8.1
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50255,
+ "order" : 0,
+ "role" : "Sergeant Joe Friday",
+ "tag" : "Jack Webb",
+ "tagKey" : "50255"
+ },
+ {
+ "id" : 50258,
+ "order" : 1,
+ "role" : "Sergeant Ed Jacobs",
+ "tag" : "Barney Phillips",
+ "tagKey" : "50258"
+ },
+ {
+ "id" : 50259,
+ "order" : 2,
+ "role" : "Henry Ross",
+ "tag" : "Lee Marvin",
+ "tagKey" : "50259"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50256,
+ "order" : 0,
+ "tag" : "James E. Moser",
+ "tagKey" : "50256"
+ }
+ ],
+ "art" : "/mock/art/studio/2110/backdrop.jpg",
+ "duration" : 1560000,
+ "grandparentRatingKey" : "2110",
+ "grandparentThumb" : "/mock/art/studio/2110/poster.png",
+ "grandparentTitle" : "Dragnet",
+ "index" : 5,
+ "key" : "/library/metadata/2224",
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1952-02-14",
+ "parentIndex" : 1,
+ "parentRatingKey" : "2314",
+ "parentTitle" : "Season 1",
+ "ratingKey" : "2224",
+ "studio" : "Mark VII Ltd.",
+ "summary" : "Friday and Jacobs question a man suspected of involvement in a disappearance. His cool denials begin to falter under persistent questioning about what happened to the missing man.",
+ "title" : "The Big Cast",
+ "type" : "episode",
+ "viewCount" : 0,
+ "year" : 1952
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0565731/",
+ "https://watch.plex.tv/show/dragnet-1951/season/1/episode/5",
+ "https://www.paleycenter.org/collection/item?item=T%3A02955",
+ "https://www.fesfilms.com/public-domain/television/drag.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 691200,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Director" : [
+ {
+ "id" : 50255,
+ "order" : 0,
+ "tag" : "Jack Webb",
+ "tagKey" : "50255"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Crime"
+ },
+ {
+ "tag" : "Drama"
+ },
+ {
+ "tag" : "Mystery"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0565911"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7.4
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50255,
+ "order" : 0,
+ "role" : "Sergeant Joe Friday",
+ "tag" : "Jack Webb",
+ "tagKey" : "50255"
+ },
+ {
+ "id" : 50258,
+ "order" : 1,
+ "role" : "Sergeant Ed Jacobs",
+ "tag" : "Barney Phillips",
+ "tagKey" : "50258"
+ },
+ {
+ "id" : 50260,
+ "order" : 2,
+ "role" : "William Harold Tanner",
+ "tag" : "Stacy Harris",
+ "tagKey" : "50260"
+ },
+ {
+ "id" : 50261,
+ "order" : 3,
+ "role" : "Robert French",
+ "tag" : "Eddie Firestone",
+ "tagKey" : "50261"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50256,
+ "order" : 0,
+ "tag" : "James E. Moser",
+ "tagKey" : "50256"
+ }
+ ],
+ "art" : "/mock/art/studio/2110/backdrop.jpg",
+ "duration" : 1560000,
+ "grandparentRatingKey" : "2110",
+ "grandparentThumb" : "/mock/art/studio/2110/poster.png",
+ "grandparentTitle" : "Dragnet",
+ "index" : 11,
+ "key" : "/library/metadata/2225",
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1952-05-08",
+ "parentIndex" : 1,
+ "parentRatingKey" : "2314",
+ "parentTitle" : "Season 1",
+ "ratingKey" : "2225",
+ "studio" : "Mark VII Ltd.",
+ "summary" : "A secretary is found beaten to death in her office. Friday and Jacobs sift through the accounts of the men who saw her that night to identify her killer.",
+ "title" : "The Big September Man",
+ "type" : "episode",
+ "viewCount" : 0,
+ "year" : 1952
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0565911/",
+ "https://watch.plex.tv/show/dragnet-1951/season/1/episode/11",
+ "https://www.fesfilms.com/public-domain/television/drag.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 691200,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "art" : "/mock/art/studio/2110/backdrop.jpg",
+ "childCount" : 1,
+ "index" : 2,
+ "key" : "/library/metadata/2315/children",
+ "leafCount" : 1,
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "parentRatingKey" : "2110",
+ "parentThumb" : "/mock/art/studio/2110/poster.png",
+ "parentTitle" : "Dragnet",
+ "ratingKey" : "2315",
+ "title" : "Season 2",
+ "type" : "season",
+ "viewedLeafCount" : 0,
+ "year" : 1952
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0043194/episodes/?season=2",
+ "https://www.fesfilms.com/public-domain/television/drag.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 691200,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Director" : [
+ {
+ "id" : 50255,
+ "order" : 0,
+ "tag" : "Jack Webb",
+ "tagKey" : "50255"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Crime"
+ },
+ {
+ "tag" : "Drama"
+ },
+ {
+ "tag" : "Mystery"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0565822"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7.5
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50255,
+ "order" : 0,
+ "role" : "Sergeant Joe Friday",
+ "tag" : "Jack Webb",
+ "tagKey" : "50255"
+ },
+ {
+ "id" : 50262,
+ "order" : 1,
+ "role" : "Captain R. A. Lohrman",
+ "tag" : "Milburn Stone",
+ "tagKey" : "50262"
+ },
+ {
+ "id" : 50263,
+ "order" : 2,
+ "role" : "Sergeant Gene Bechtel",
+ "tag" : "Herbert Ellis",
+ "tagKey" : "50263"
+ },
+ {
+ "id" : 50264,
+ "order" : 3,
+ "role" : "Walter Harrison",
+ "tag" : "Paul Richards",
+ "tagKey" : "50264"
+ },
+ {
+ "id" : 50265,
+ "order" : 4,
+ "role" : "Ruth Harrison",
+ "tag" : "Lillian Buyeff",
+ "tagKey" : "50265"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50256,
+ "order" : 0,
+ "tag" : "James E. Moser",
+ "tagKey" : "50256"
+ }
+ ],
+ "art" : "/mock/art/studio/2110/backdrop.jpg",
+ "duration" : 1560000,
+ "grandparentRatingKey" : "2110",
+ "grandparentThumb" : "/mock/art/studio/2110/poster.png",
+ "grandparentTitle" : "Dragnet",
+ "index" : 1,
+ "key" : "/library/metadata/2226",
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1952-09-11",
+ "parentIndex" : 2,
+ "parentRatingKey" : "2315",
+ "parentTitle" : "Season 2",
+ "ratingKey" : "2226",
+ "studio" : "Mark VII Ltd.",
+ "summary" : "A man climbs onto a ninth-floor ledge and threatens to jump at a set time. Friday and the emergency crews race to reach him before the deadline.",
+ "title" : "The Big Jump",
+ "type" : "episode",
+ "viewCount" : 0,
+ "year" : 1952
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0565822/",
+ "https://pro.imdb.com/title/tt0565822/",
+ "https://tubitv.com/tv-shows/635964/s02-e01-the-big-jump",
+ "https://www.fesfilms.com/public-domain/television/drag.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 777600,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Country" : [
+ {
+ "tag" : "United States"
+ }
+ ],
+ "Director" : [
+ {
+ "id" : 50266,
+ "order" : 0,
+ "tag" : "Christian Nyby",
+ "tagKey" : "50266"
+ },
+ {
+ "id" : 50267,
+ "order" : 1,
+ "tag" : "Lewis Allen",
+ "tagKey" : "50267"
+ },
+ {
+ "id" : 50241,
+ "order" : 2,
+ "tag" : "John Rich",
+ "tagKey" : "50241"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Western"
+ },
+ {
+ "tag" : "Drama"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0052451"
+ }
+ ],
+ "Producer" : [
+ {
+ "id" : 50268,
+ "order" : 0,
+ "tag" : "David Dortort",
+ "tagKey" : "50268"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7.3
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50269,
+ "order" : 0,
+ "role" : "Ben Cartwright",
+ "tag" : "Lorne Greene",
+ "tagKey" : "50269"
+ },
+ {
+ "id" : 50270,
+ "order" : 1,
+ "role" : "Adam Cartwright",
+ "tag" : "Pernell Roberts",
+ "tagKey" : "50270"
+ },
+ {
+ "id" : 50271,
+ "order" : 2,
+ "role" : "Eric 'Hoss' Cartwright",
+ "tag" : "Dan Blocker",
+ "tagKey" : "50271"
+ },
+ {
+ "id" : 50272,
+ "order" : 3,
+ "role" : "Joseph 'Little Joe' Cartwright",
+ "tag" : "Michael Landon",
+ "tagKey" : "50272"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50268,
+ "order" : 0,
+ "tag" : "David Dortort",
+ "tagKey" : "50268"
+ }
+ ],
+ "art" : "/mock/art/studio/2111/backdrop.jpg",
+ "childCount" : 2,
+ "contentRating" : "TV-PG",
+ "key" : "/library/metadata/2111/children",
+ "leafCount" : 3,
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1959-09-12",
+ "ratingKey" : "2111",
+ "studio" : "National Broadcasting Company (NBC)",
+ "summary" : "Ben Cartwright and his sons Adam, Hoss, and Little Joe run the Ponderosa ranch near Virginia City, Nevada. Their loyalty to one another is tested by frontier disputes, dangerous strangers, and the changing West.",
+ "thumb" : "/mock/art/studio/2111/poster.png",
+ "title" : "Bonanza",
+ "titleSort" : "Bonanza",
+ "type" : "show",
+ "viewedLeafCount" : 0,
+ "year" : 1959
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://watch.plex.tv/show/bonanza",
+ "https://www.imdb.com/title/tt0052451/",
+ "https://www.fesfilms.com/public-domain/television/bon.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 777600,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "art" : "/mock/art/studio/2111/backdrop.jpg",
+ "childCount" : 2,
+ "index" : 1,
+ "key" : "/library/metadata/2316/children",
+ "leafCount" : 2,
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "parentRatingKey" : "2111",
+ "parentThumb" : "/mock/art/studio/2111/poster.png",
+ "parentTitle" : "Bonanza",
+ "ratingKey" : "2316",
+ "title" : "Season 1",
+ "type" : "season",
+ "viewedLeafCount" : 0,
+ "year" : 1959
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0052451/episodes/?season=1",
+ "https://www.fesfilms.com/public-domain/television/bon.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 777600,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Director" : [
+ {
+ "id" : 50266,
+ "order" : 0,
+ "tag" : "Christian Nyby",
+ "tagKey" : "50266"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Western"
+ },
+ {
+ "tag" : "Drama"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0529674"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 8.1
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50269,
+ "order" : 0,
+ "role" : "Ben Cartwright",
+ "tag" : "Lorne Greene",
+ "tagKey" : "50269"
+ },
+ {
+ "id" : 50270,
+ "order" : 1,
+ "role" : "Adam Cartwright",
+ "tag" : "Pernell Roberts",
+ "tagKey" : "50270"
+ },
+ {
+ "id" : 50271,
+ "order" : 2,
+ "role" : "Eric 'Hoss' Cartwright",
+ "tag" : "Dan Blocker",
+ "tagKey" : "50271"
+ },
+ {
+ "id" : 50272,
+ "order" : 3,
+ "role" : "Joseph 'Little Joe' Cartwright",
+ "tag" : "Michael Landon",
+ "tagKey" : "50272"
+ },
+ {
+ "id" : 50274,
+ "order" : 4,
+ "role" : "Lassiter",
+ "tag" : "Vic Morrow",
+ "tagKey" : "50274"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50273,
+ "order" : 0,
+ "tag" : "Clair Huffaker",
+ "tagKey" : "50273"
+ }
+ ],
+ "art" : "/mock/art/studio/2111/backdrop.jpg",
+ "duration" : 2940000,
+ "grandparentRatingKey" : "2111",
+ "grandparentThumb" : "/mock/art/studio/2111/poster.png",
+ "grandparentTitle" : "Bonanza",
+ "index" : 26,
+ "key" : "/library/metadata/2227",
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1960-03-19",
+ "parentIndex" : 1,
+ "parentRatingKey" : "2316",
+ "parentTitle" : "Season 1",
+ "ratingKey" : "2227",
+ "studio" : "National Broadcasting Company (NBC)",
+ "summary" : "Ben and Adam face execution after being convicted of a murder they did not commit. Hoss and Little Joe seek help, while a mysterious drifter takes an interest in the case.",
+ "title" : "The Avenger",
+ "type" : "episode",
+ "viewCount" : 0,
+ "year" : 1960
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0529674/",
+ "https://www.orfmax.net/wp-content/uploads/2025/02/Bonanza-Episode-Guide.pdf",
+ "https://www.fesfilms.com/public-domain/television/bon.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 777600,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Director" : [
+ {
+ "id" : 50267,
+ "order" : 0,
+ "tag" : "Lewis Allen",
+ "tagKey" : "50267"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Western"
+ },
+ {
+ "tag" : "Drama"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0529757"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 6.6
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50269,
+ "order" : 0,
+ "role" : "Ben Cartwright",
+ "tag" : "Lorne Greene",
+ "tagKey" : "50269"
+ },
+ {
+ "id" : 50270,
+ "order" : 1,
+ "role" : "Adam Cartwright",
+ "tag" : "Pernell Roberts",
+ "tagKey" : "50270"
+ },
+ {
+ "id" : 50271,
+ "order" : 2,
+ "role" : "Eric 'Hoss' Cartwright",
+ "tag" : "Dan Blocker",
+ "tagKey" : "50271"
+ },
+ {
+ "id" : 50272,
+ "order" : 3,
+ "role" : "Joseph 'Little Joe' Cartwright",
+ "tag" : "Michael Landon",
+ "tagKey" : "50272"
+ },
+ {
+ "id" : 50276,
+ "order" : 4,
+ "role" : "Lady Beatrice Dunsford",
+ "tag" : "Hazel Court",
+ "tagKey" : "50276"
+ },
+ {
+ "id" : 50277,
+ "order" : 5,
+ "role" : "Lord Marion Dunsford",
+ "tag" : "Edward Ashley",
+ "tagKey" : "50277"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50275,
+ "order" : 0,
+ "tag" : "Bill S. Ballinger",
+ "tagKey" : "50275"
+ }
+ ],
+ "art" : "/mock/art/studio/2111/backdrop.jpg",
+ "duration" : 2940000,
+ "grandparentRatingKey" : "2111",
+ "grandparentThumb" : "/mock/art/studio/2111/poster.png",
+ "grandparentTitle" : "Bonanza",
+ "index" : 27,
+ "key" : "/library/metadata/2228",
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1960-03-26",
+ "parentIndex" : 1,
+ "parentRatingKey" : "2316",
+ "parentTitle" : "Season 1",
+ "ratingKey" : "2228",
+ "studio" : "National Broadcasting Company (NBC)",
+ "summary" : "An English couple visits the Ponderosa, where Lady Beatrice doubts her husband’s courage. A hunting expedition with Adam puts their strained marriage to a dangerous test.",
+ "title" : "The Last Trophy",
+ "type" : "episode",
+ "viewCount" : 0,
+ "year" : 1960
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0529757/",
+ "https://watch.plex.tv/show/bonanza/season/1/episode/27",
+ "https://www.imdb.com/title/tt0529757/fullcredits/",
+ "https://www.fesfilms.com/public-domain/television/bon.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 777600,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "art" : "/mock/art/studio/2111/backdrop.jpg",
+ "childCount" : 1,
+ "index" : 2,
+ "key" : "/library/metadata/2317/children",
+ "leafCount" : 1,
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "parentRatingKey" : "2111",
+ "parentThumb" : "/mock/art/studio/2111/poster.png",
+ "parentTitle" : "Bonanza",
+ "ratingKey" : "2317",
+ "title" : "Season 2",
+ "type" : "season",
+ "viewedLeafCount" : 0,
+ "year" : 1960
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0052451/episodes/?season=2",
+ "https://www.fesfilms.com/public-domain/television/bon.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 777600,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Director" : [
+ {
+ "id" : 50241,
+ "order" : 0,
+ "tag" : "John Rich",
+ "tagKey" : "50241"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Western"
+ },
+ {
+ "tag" : "Drama"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0529652"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7.3
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50269,
+ "order" : 0,
+ "role" : "Ben Cartwright",
+ "tag" : "Lorne Greene",
+ "tagKey" : "50269"
+ },
+ {
+ "id" : 50270,
+ "order" : 1,
+ "role" : "Adam Cartwright",
+ "tag" : "Pernell Roberts",
+ "tagKey" : "50270"
+ },
+ {
+ "id" : 50271,
+ "order" : 2,
+ "role" : "Eric 'Hoss' Cartwright",
+ "tag" : "Dan Blocker",
+ "tagKey" : "50271"
+ },
+ {
+ "id" : 50272,
+ "order" : 3,
+ "role" : "Joseph 'Little Joe' Cartwright",
+ "tag" : "Michael Landon",
+ "tagKey" : "50272"
+ },
+ {
+ "id" : 50279,
+ "order" : 4,
+ "role" : "Sam Kirby",
+ "tag" : "Ben Cooper",
+ "tagKey" : "50279"
+ },
+ {
+ "id" : 50280,
+ "order" : 5,
+ "role" : "John Pardo",
+ "tag" : "Jack Lambert",
+ "tagKey" : "50280"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50278,
+ "order" : 0,
+ "tag" : "Halsted Welles",
+ "tagKey" : "50278"
+ }
+ ],
+ "art" : "/mock/art/studio/2111/backdrop.jpg",
+ "duration" : 2940000,
+ "grandparentRatingKey" : "2111",
+ "grandparentThumb" : "/mock/art/studio/2111/poster.png",
+ "grandparentTitle" : "Bonanza",
+ "index" : 1,
+ "key" : "/library/metadata/2229",
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1960-09-10",
+ "parentIndex" : 2,
+ "parentRatingKey" : "2317",
+ "parentTitle" : "Season 2",
+ "ratingKey" : "2229",
+ "studio" : "National Broadcasting Company (NBC)",
+ "summary" : "After a bank robbery, a young outlaw takes a job at the Ponderosa to monitor the search for his gang. Little Joe grows suspicious of the new ranch hand.",
+ "title" : "Showdown",
+ "type" : "episode",
+ "viewCount" : 0,
+ "year" : 1960
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0529652/",
+ "https://watch.plex.tv/show/bonanza/season/2/episode/1",
+ "https://www.imdb.com/title/tt0529652/fullcredits/",
+ "https://www.fesfilms.com/public-domain/television/bon.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 864000,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Country" : [
+ {
+ "tag" : "United States"
+ }
+ ],
+ "Director" : [
+ {
+ "id" : 50281,
+ "order" : 0,
+ "tag" : "Leonard Valenta",
+ "tagKey" : "50281"
+ },
+ {
+ "id" : 50282,
+ "order" : 1,
+ "tag" : "Charles S. Dubin",
+ "tagKey" : "50282"
+ },
+ {
+ "id" : 50283,
+ "order" : 2,
+ "tag" : "Don Medford",
+ "tagKey" : "50283"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ },
+ {
+ "tag" : "Drama"
+ },
+ {
+ "tag" : "Horror"
+ },
+ {
+ "tag" : "Mystery"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0043238"
+ }
+ ],
+ "Producer" : [
+ {
+ "id" : 50287,
+ "order" : 0,
+ "tag" : "George F. Foley Jr.",
+ "tagKey" : "50287"
+ },
+ {
+ "id" : 50288,
+ "order" : 1,
+ "tag" : "Mort Abrahams",
+ "tagKey" : "50288"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 7
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50202,
+ "order" : 0,
+ "tag" : "Thomas Mitchell",
+ "tagKey" : "50202"
+ },
+ {
+ "id" : 50289,
+ "order" : 1,
+ "tag" : "Lon McCallister",
+ "tagKey" : "50289"
+ },
+ {
+ "id" : 50290,
+ "order" : 2,
+ "tag" : "Robert Allen",
+ "tagKey" : "50290"
+ },
+ {
+ "id" : 50291,
+ "order" : 3,
+ "tag" : "Edgar Stehli",
+ "tagKey" : "50291"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50284,
+ "order" : 0,
+ "tag" : "Theodore Sturgeon",
+ "tagKey" : "50284"
+ },
+ {
+ "id" : 50285,
+ "order" : 1,
+ "tag" : "Philip Wylie",
+ "tagKey" : "50285"
+ },
+ {
+ "id" : 50286,
+ "order" : 2,
+ "tag" : "Mel Goldberg",
+ "tagKey" : "50286"
+ }
+ ],
+ "art" : "/mock/art/studio/2112/backdrop.jpg",
+ "childCount" : 1,
+ "contentRating" : "TV-G",
+ "key" : "/library/metadata/2112/children",
+ "leafCount" : 3,
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1951-08-03",
+ "ratingKey" : "2112",
+ "studio" : "George F. Foley Productions",
+ "summary" : "A science-fiction anthology presents independent stories of alien encounters, troubling inventions, and experiments with unforeseen consequences. Each tale follows a different cast into a scientific or supernatural mystery.",
+ "thumb" : "/mock/art/studio/2112/poster.png",
+ "title" : "Tales of Tomorrow",
+ "titleSort" : "Tales of Tomorrow",
+ "type" : "show",
+ "viewedLeafCount" : 0,
+ "year" : 1951
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://watch.plex.tv/show/tales-of-tomorrow",
+ "https://www.imdb.com/title/tt0043238/",
+ "https://www.fesfilms.com/public-domain/television/tales.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 864000,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "art" : "/mock/art/studio/2112/backdrop.jpg",
+ "childCount" : 3,
+ "index" : 1,
+ "key" : "/library/metadata/2318/children",
+ "leafCount" : 3,
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "parentRatingKey" : "2112",
+ "parentThumb" : "/mock/art/studio/2112/poster.png",
+ "parentTitle" : "Tales of Tomorrow",
+ "ratingKey" : "2318",
+ "title" : "Season 1",
+ "type" : "season",
+ "viewedLeafCount" : 0,
+ "year" : 1951
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0043238/episodes/?season=1",
+ "https://www.fesfilms.com/public-domain/television/tales.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 864000,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Director" : [
+ {
+ "id" : 50281,
+ "order" : 0,
+ "tag" : "Leonard Valenta",
+ "tagKey" : "50281"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ },
+ {
+ "tag" : "Drama"
+ },
+ {
+ "tag" : "Horror"
+ },
+ {
+ "tag" : "Mystery"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0717091"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 6.3
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50289,
+ "order" : 0,
+ "role" : "Gordon Kent",
+ "tag" : "Lon McCallister",
+ "tagKey" : "50289"
+ },
+ {
+ "id" : 50292,
+ "order" : 1,
+ "role" : "Professor Adrian Sykes",
+ "tag" : "Martin Brandt",
+ "tagKey" : "50292"
+ },
+ {
+ "id" : 50293,
+ "order" : 2,
+ "role" : "Prosecutor",
+ "tag" : "William Lally",
+ "tagKey" : "50293"
+ },
+ {
+ "id" : 50294,
+ "order" : 3,
+ "role" : "Judge",
+ "tag" : "Bernard Lenrow",
+ "tagKey" : "50294"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50284,
+ "order" : 0,
+ "tag" : "Theodore Sturgeon",
+ "tagKey" : "50284"
+ }
+ ],
+ "art" : "/mock/art/studio/2112/backdrop.jpg",
+ "duration" : 1500000,
+ "grandparentRatingKey" : "2112",
+ "grandparentThumb" : "/mock/art/studio/2112/poster.png",
+ "grandparentTitle" : "Tales of Tomorrow",
+ "index" : 1,
+ "key" : "/library/metadata/2230",
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1951-08-03",
+ "parentIndex" : 1,
+ "parentRatingKey" : "2318",
+ "parentTitle" : "Season 1",
+ "ratingKey" : "2230",
+ "studio" : "George F. Foley Productions",
+ "summary" : "An inventor on trial for murder describes a remarkable discovery inside a sealed cavern. He struggles to convince the court that what he found threatens far more than his own freedom.",
+ "title" : "Verdict from Space",
+ "type" : "episode",
+ "viewCount" : 0,
+ "year" : 1951
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0717091/",
+ "https://www.imdb.com/title/tt0717091/fullcredits/",
+ "https://tubitv.com/tv-shows/200010010/s01-e01-verdict-from-space",
+ "https://www.fesfilms.com/public-domain/television/tales.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 864000,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Director" : [
+ {
+ "id" : 50281,
+ "order" : 0,
+ "tag" : "Leonard Valenta",
+ "tagKey" : "50281"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ },
+ {
+ "tag" : "Drama"
+ },
+ {
+ "tag" : "Horror"
+ },
+ {
+ "tag" : "Mystery"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0717022"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 5.9
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50290,
+ "order" : 0,
+ "tag" : "Robert Allen",
+ "tagKey" : "50290"
+ },
+ {
+ "id" : 50296,
+ "order" : 1,
+ "tag" : "Ann Loring",
+ "tagKey" : "50296"
+ },
+ {
+ "id" : 50297,
+ "order" : 2,
+ "tag" : "Philip Faversham",
+ "tagKey" : "50297"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50295,
+ "order" : 0,
+ "tag" : "Charles O'Neill",
+ "tagKey" : "50295"
+ },
+ {
+ "id" : 50285,
+ "order" : 1,
+ "tag" : "Philip Wylie",
+ "tagKey" : "50285"
+ }
+ ],
+ "art" : "/mock/art/studio/2112/backdrop.jpg",
+ "duration" : 1500000,
+ "grandparentRatingKey" : "2112",
+ "grandparentThumb" : "/mock/art/studio/2112/poster.png",
+ "grandparentTitle" : "Tales of Tomorrow",
+ "index" : 2,
+ "key" : "/library/metadata/2231",
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1951-08-10",
+ "parentIndex" : 1,
+ "parentRatingKey" : "2318",
+ "parentTitle" : "Season 1",
+ "ratingKey" : "2231",
+ "studio" : "George F. Foley Productions",
+ "summary" : "Scientists discover that a colleague’s planned nuclear experiment could have catastrophic consequences. Reaching the isolated researcher before he begins becomes a desperate race against time.",
+ "title" : "Blunder",
+ "type" : "episode",
+ "viewCount" : 0,
+ "year" : 1951
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0717022/",
+ "https://tubitv.com/en-gb/tv-shows/200010011/s01-e02-blunder",
+ "https://tv.apple.com/gb/show/tales-of-tomorrow/umc.cmc.4llnf271kj65hbf2x105ihzv4",
+ "https://en.wikipedia.org/wiki/Tales_of_Tomorrow",
+ "https://www.fesfilms.com/public-domain/television/tales.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 864000,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Director" : [
+ {
+ "id" : 50282,
+ "order" : 0,
+ "tag" : "Charles S. Dubin",
+ "tagKey" : "50282"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Science Fiction"
+ },
+ {
+ "tag" : "Drama"
+ },
+ {
+ "tag" : "Horror"
+ },
+ {
+ "tag" : "Mystery"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0717056"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 6.3
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50202,
+ "order" : 0,
+ "role" : "Professor Frederick Vaneck",
+ "tag" : "Thomas Mitchell",
+ "tagKey" : "50202"
+ },
+ {
+ "id" : 50291,
+ "order" : 1,
+ "role" : "Mr. Cave",
+ "tag" : "Edgar Stehli",
+ "tagKey" : "50291"
+ },
+ {
+ "id" : 50299,
+ "order" : 2,
+ "role" : "Mrs. Cave",
+ "tag" : "Josephine Brown",
+ "tagKey" : "50299"
+ },
+ {
+ "id" : 50300,
+ "order" : 3,
+ "role" : "Georgette",
+ "tag" : "Sally Gracie",
+ "tagKey" : "50300"
+ },
+ {
+ "id" : 50301,
+ "order" : 4,
+ "role" : "Walker",
+ "tag" : "Gage Clarke",
+ "tagKey" : "50301"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50298,
+ "order" : 0,
+ "tag" : "H. G. Wells",
+ "tagKey" : "50298"
+ },
+ {
+ "id" : 50286,
+ "order" : 1,
+ "tag" : "Mel Goldberg",
+ "tagKey" : "50286"
+ }
+ ],
+ "art" : "/mock/art/studio/2112/backdrop.jpg",
+ "duration" : 1500000,
+ "grandparentRatingKey" : "2112",
+ "grandparentThumb" : "/mock/art/studio/2112/poster.png",
+ "grandparentTitle" : "Tales of Tomorrow",
+ "index" : 9,
+ "key" : "/library/metadata/2232",
+ "librarySectionID" : "library-tv-shows",
+ "librarySectionTitle" : "TV Shows",
+ "originallyAvailableAt" : "1951-10-12",
+ "parentIndex" : 1,
+ "parentRatingKey" : "2318",
+ "parentTitle" : "Season 1",
+ "ratingKey" : "2232",
+ "studio" : "George F. Foley Productions",
+ "summary" : "An antique dealer brings a strange crystal egg to a professor for examination. The images within suggest a window onto another world, while an insistent buyer presses to obtain it.",
+ "title" : "The Crystal Egg",
+ "type" : "episode",
+ "viewCount" : 0,
+ "year" : 1951
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://www.imdb.com/title/tt0717056/",
+ "https://www.imdb.com/title/tt0717056/fullcredits/",
+ "https://tv.apple.com/gb/show/tales-of-tomorrow/umc.cmc.4llnf271kj65hbf2x105ihzv4",
+ "https://www.fesfilms.com/public-domain/television/tales.html"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 900,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "childCount" : 1,
+ "key" : "/library/metadata/3003/children",
+ "leafCount" : 29,
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "ratingKey" : "3003",
+ "summary" : "Mary Shelley (1797–1851), author of Frankenstein.",
+ "title" : "Mary Shelley",
+ "type" : "artist",
+ "viewedLeafCount" : 0
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/frankenstein-or-the-modern-prometheus-1818-by-mary-wollstonecraft-shelley/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 900,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "childCount" : 29,
+ "duration" : 28755000,
+ "key" : "/library/metadata/3104/children",
+ "leafCount" : 29,
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-10-31",
+ "parentRatingKey" : "3003",
+ "parentTitle" : "Mary Shelley",
+ "ratingKey" : "3104",
+ "studio" : "LibriVox",
+ "summary" : "Victor Frankenstein creates a living being, then abandons it, setting creator and creature on a path of isolation and revenge. Cori Samuel reads the original 1818 text in this 2011 LibriVox edition.",
+ "thumb" : "/mock/art/studio/3104/cover.png",
+ "title" : "Frankenstein",
+ "titleSort" : "Frankenstein",
+ "type" : "album",
+ "viewedLeafCount" : 0,
+ "year" : 2011
+ },
+ "relatedIDs" : [
+ "3101",
+ "3102",
+ "3103"
+ ],
+ "sources" : [
+ "https://librivox.org/frankenstein-or-the-modern-prometheus-1818-by-mary-wollstonecraft-shelley/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 900,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 276000,
+ "grandparentRatingKey" : "3003",
+ "grandparentTitle" : "Mary Shelley",
+ "index" : 1,
+ "key" : "/library/metadata/32401",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-10-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3104",
+ "parentThumb" : "/mock/art/studio/3104/cover.png",
+ "parentTitle" : "Frankenstein",
+ "ratingKey" : "32401",
+ "studio" : "LibriVox",
+ "summary" : "Read by Cori Samuel.",
+ "title" : "Dedication and Preface",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/frankenstein-or-the-modern-prometheus-1818-by-mary-wollstonecraft-shelley/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 900,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 471000,
+ "grandparentRatingKey" : "3003",
+ "grandparentTitle" : "Mary Shelley",
+ "index" : 2,
+ "key" : "/library/metadata/32402",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-10-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3104",
+ "parentThumb" : "/mock/art/studio/3104/cover.png",
+ "parentTitle" : "Frankenstein",
+ "ratingKey" : "32402",
+ "studio" : "LibriVox",
+ "summary" : "Read by Cori Samuel.",
+ "title" : "Vol. I, Letter I",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/frankenstein-or-the-modern-prometheus-1818-by-mary-wollstonecraft-shelley/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 900,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 409000,
+ "grandparentRatingKey" : "3003",
+ "grandparentTitle" : "Mary Shelley",
+ "index" : 3,
+ "key" : "/library/metadata/32403",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-10-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3104",
+ "parentThumb" : "/mock/art/studio/3104/cover.png",
+ "parentTitle" : "Frankenstein",
+ "ratingKey" : "32403",
+ "studio" : "LibriVox",
+ "summary" : "Read by Cori Samuel.",
+ "title" : "Vol. I, Letter II",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/frankenstein-or-the-modern-prometheus-1818-by-mary-wollstonecraft-shelley/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 900,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 109000,
+ "grandparentRatingKey" : "3003",
+ "grandparentTitle" : "Mary Shelley",
+ "index" : 4,
+ "key" : "/library/metadata/32404",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-10-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3104",
+ "parentThumb" : "/mock/art/studio/3104/cover.png",
+ "parentTitle" : "Frankenstein",
+ "ratingKey" : "32404",
+ "studio" : "LibriVox",
+ "summary" : "Read by Cori Samuel.",
+ "title" : "Vol. I, Letter III",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/frankenstein-or-the-modern-prometheus-1818-by-mary-wollstonecraft-shelley/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 900,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 883000,
+ "grandparentRatingKey" : "3003",
+ "grandparentTitle" : "Mary Shelley",
+ "index" : 5,
+ "key" : "/library/metadata/32405",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-10-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3104",
+ "parentThumb" : "/mock/art/studio/3104/cover.png",
+ "parentTitle" : "Frankenstein",
+ "ratingKey" : "32405",
+ "studio" : "LibriVox",
+ "summary" : "Read by Cori Samuel.",
+ "title" : "Vol. I, Letter IV",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/frankenstein-or-the-modern-prometheus-1818-by-mary-wollstonecraft-shelley/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 900,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1198000,
+ "grandparentRatingKey" : "3003",
+ "grandparentTitle" : "Mary Shelley",
+ "index" : 6,
+ "key" : "/library/metadata/32406",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-10-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3104",
+ "parentThumb" : "/mock/art/studio/3104/cover.png",
+ "parentTitle" : "Frankenstein",
+ "ratingKey" : "32406",
+ "studio" : "LibriVox",
+ "summary" : "Read by Cori Samuel.",
+ "title" : "Vol. I, Chapter I",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/frankenstein-or-the-modern-prometheus-1818-by-mary-wollstonecraft-shelley/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 900,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 915000,
+ "grandparentRatingKey" : "3003",
+ "grandparentTitle" : "Mary Shelley",
+ "index" : 7,
+ "key" : "/library/metadata/32407",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-10-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3104",
+ "parentThumb" : "/mock/art/studio/3104/cover.png",
+ "parentTitle" : "Frankenstein",
+ "ratingKey" : "32407",
+ "studio" : "LibriVox",
+ "summary" : "Read by Cori Samuel.",
+ "title" : "Vol. I, Chapter II",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/frankenstein-or-the-modern-prometheus-1818-by-mary-wollstonecraft-shelley/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 900,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1042000,
+ "grandparentRatingKey" : "3003",
+ "grandparentTitle" : "Mary Shelley",
+ "index" : 8,
+ "key" : "/library/metadata/32408",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-10-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3104",
+ "parentThumb" : "/mock/art/studio/3104/cover.png",
+ "parentTitle" : "Frankenstein",
+ "ratingKey" : "32408",
+ "studio" : "LibriVox",
+ "summary" : "Read by Cori Samuel.",
+ "title" : "Vol. I, Chapter III",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/frankenstein-or-the-modern-prometheus-1818-by-mary-wollstonecraft-shelley/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 900,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 938000,
+ "grandparentRatingKey" : "3003",
+ "grandparentTitle" : "Mary Shelley",
+ "index" : 9,
+ "key" : "/library/metadata/32409",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-10-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3104",
+ "parentThumb" : "/mock/art/studio/3104/cover.png",
+ "parentTitle" : "Frankenstein",
+ "ratingKey" : "32409",
+ "studio" : "LibriVox",
+ "summary" : "Read by Cori Samuel.",
+ "title" : "Vol. I, Chapter IV",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/frankenstein-or-the-modern-prometheus-1818-by-mary-wollstonecraft-shelley/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 900,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1048000,
+ "grandparentRatingKey" : "3003",
+ "grandparentTitle" : "Mary Shelley",
+ "index" : 10,
+ "key" : "/library/metadata/32410",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-10-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3104",
+ "parentThumb" : "/mock/art/studio/3104/cover.png",
+ "parentTitle" : "Frankenstein",
+ "ratingKey" : "32410",
+ "studio" : "LibriVox",
+ "summary" : "Read by Cori Samuel.",
+ "title" : "Vol. I, Chapter V",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/frankenstein-or-the-modern-prometheus-1818-by-mary-wollstonecraft-shelley/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 900,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1496000,
+ "grandparentRatingKey" : "3003",
+ "grandparentTitle" : "Mary Shelley",
+ "index" : 11,
+ "key" : "/library/metadata/32411",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-10-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3104",
+ "parentThumb" : "/mock/art/studio/3104/cover.png",
+ "parentTitle" : "Frankenstein",
+ "ratingKey" : "32411",
+ "studio" : "LibriVox",
+ "summary" : "Read by Cori Samuel.",
+ "title" : "Vol. I, Chapter VI",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/frankenstein-or-the-modern-prometheus-1818-by-mary-wollstonecraft-shelley/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 900,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1187000,
+ "grandparentRatingKey" : "3003",
+ "grandparentTitle" : "Mary Shelley",
+ "index" : 12,
+ "key" : "/library/metadata/32412",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-10-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3104",
+ "parentThumb" : "/mock/art/studio/3104/cover.png",
+ "parentTitle" : "Frankenstein",
+ "ratingKey" : "32412",
+ "studio" : "LibriVox",
+ "summary" : "Read by Cori Samuel.",
+ "title" : "Vol. I, Chapter VII",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/frankenstein-or-the-modern-prometheus-1818-by-mary-wollstonecraft-shelley/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 900,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 821000,
+ "grandparentRatingKey" : "3003",
+ "grandparentTitle" : "Mary Shelley",
+ "index" : 13,
+ "key" : "/library/metadata/32413",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-10-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3104",
+ "parentThumb" : "/mock/art/studio/3104/cover.png",
+ "parentTitle" : "Frankenstein",
+ "ratingKey" : "32413",
+ "studio" : "LibriVox",
+ "summary" : "Read by Cori Samuel.",
+ "title" : "Vol. II, Chapter I",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/frankenstein-or-the-modern-prometheus-1818-by-mary-wollstonecraft-shelley/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 900,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 895000,
+ "grandparentRatingKey" : "3003",
+ "grandparentTitle" : "Mary Shelley",
+ "index" : 14,
+ "key" : "/library/metadata/32414",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-10-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3104",
+ "parentThumb" : "/mock/art/studio/3104/cover.png",
+ "parentTitle" : "Frankenstein",
+ "ratingKey" : "32414",
+ "studio" : "LibriVox",
+ "summary" : "Read by Cori Samuel.",
+ "title" : "Vol. II, Chapter II",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/frankenstein-or-the-modern-prometheus-1818-by-mary-wollstonecraft-shelley/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 900,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1102000,
+ "grandparentRatingKey" : "3003",
+ "grandparentTitle" : "Mary Shelley",
+ "index" : 15,
+ "key" : "/library/metadata/32415",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-10-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3104",
+ "parentThumb" : "/mock/art/studio/3104/cover.png",
+ "parentTitle" : "Frankenstein",
+ "ratingKey" : "32415",
+ "studio" : "LibriVox",
+ "summary" : "Read by Cori Samuel.",
+ "title" : "Vol. II, Chapter III",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/frankenstein-or-the-modern-prometheus-1818-by-mary-wollstonecraft-shelley/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 900,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 834000,
+ "grandparentRatingKey" : "3003",
+ "grandparentTitle" : "Mary Shelley",
+ "index" : 16,
+ "key" : "/library/metadata/32416",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-10-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3104",
+ "parentThumb" : "/mock/art/studio/3104/cover.png",
+ "parentTitle" : "Frankenstein",
+ "ratingKey" : "32416",
+ "studio" : "LibriVox",
+ "summary" : "Read by Cori Samuel.",
+ "title" : "Vol. II, Chapter IV",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/frankenstein-or-the-modern-prometheus-1818-by-mary-wollstonecraft-shelley/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 900,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 852000,
+ "grandparentRatingKey" : "3003",
+ "grandparentTitle" : "Mary Shelley",
+ "index" : 17,
+ "key" : "/library/metadata/32417",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-10-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3104",
+ "parentThumb" : "/mock/art/studio/3104/cover.png",
+ "parentTitle" : "Frankenstein",
+ "ratingKey" : "32417",
+ "studio" : "LibriVox",
+ "summary" : "Read by Cori Samuel.",
+ "title" : "Vol. II, Chapter V",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/frankenstein-or-the-modern-prometheus-1818-by-mary-wollstonecraft-shelley/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 900,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 696000,
+ "grandparentRatingKey" : "3003",
+ "grandparentTitle" : "Mary Shelley",
+ "index" : 18,
+ "key" : "/library/metadata/32418",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-10-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3104",
+ "parentThumb" : "/mock/art/studio/3104/cover.png",
+ "parentTitle" : "Frankenstein",
+ "ratingKey" : "32418",
+ "studio" : "LibriVox",
+ "summary" : "Read by Cori Samuel.",
+ "title" : "Vol. II, Chapter VI",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/frankenstein-or-the-modern-prometheus-1818-by-mary-wollstonecraft-shelley/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 900,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1229000,
+ "grandparentRatingKey" : "3003",
+ "grandparentTitle" : "Mary Shelley",
+ "index" : 19,
+ "key" : "/library/metadata/32419",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-10-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3104",
+ "parentThumb" : "/mock/art/studio/3104/cover.png",
+ "parentTitle" : "Frankenstein",
+ "ratingKey" : "32419",
+ "studio" : "LibriVox",
+ "summary" : "Read by Cori Samuel.",
+ "title" : "Vol. II, Chapter VII",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/frankenstein-or-the-modern-prometheus-1818-by-mary-wollstonecraft-shelley/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 900,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1195000,
+ "grandparentRatingKey" : "3003",
+ "grandparentTitle" : "Mary Shelley",
+ "index" : 20,
+ "key" : "/library/metadata/32420",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-10-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3104",
+ "parentThumb" : "/mock/art/studio/3104/cover.png",
+ "parentTitle" : "Frankenstein",
+ "ratingKey" : "32420",
+ "studio" : "LibriVox",
+ "summary" : "Read by Cori Samuel.",
+ "title" : "Vol. II, Chapter VIII",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/frankenstein-or-the-modern-prometheus-1818-by-mary-wollstonecraft-shelley/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 900,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 776000,
+ "grandparentRatingKey" : "3003",
+ "grandparentTitle" : "Mary Shelley",
+ "index" : 21,
+ "key" : "/library/metadata/32421",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-10-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3104",
+ "parentThumb" : "/mock/art/studio/3104/cover.png",
+ "parentTitle" : "Frankenstein",
+ "ratingKey" : "32421",
+ "studio" : "LibriVox",
+ "summary" : "Read by Cori Samuel.",
+ "title" : "Vol. II, Chapter IX",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/frankenstein-or-the-modern-prometheus-1818-by-mary-wollstonecraft-shelley/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 900,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1081000,
+ "grandparentRatingKey" : "3003",
+ "grandparentTitle" : "Mary Shelley",
+ "index" : 22,
+ "key" : "/library/metadata/32422",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-10-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3104",
+ "parentThumb" : "/mock/art/studio/3104/cover.png",
+ "parentTitle" : "Frankenstein",
+ "ratingKey" : "32422",
+ "studio" : "LibriVox",
+ "summary" : "Read by Cori Samuel.",
+ "title" : "Vol. III, Chapter I",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/frankenstein-or-the-modern-prometheus-1818-by-mary-wollstonecraft-shelley/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 900,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 961000,
+ "grandparentRatingKey" : "3003",
+ "grandparentTitle" : "Mary Shelley",
+ "index" : 23,
+ "key" : "/library/metadata/32423",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-10-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3104",
+ "parentThumb" : "/mock/art/studio/3104/cover.png",
+ "parentTitle" : "Frankenstein",
+ "ratingKey" : "32423",
+ "studio" : "LibriVox",
+ "summary" : "Read by Cori Samuel.",
+ "title" : "Vol. III, Chapter II",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/frankenstein-or-the-modern-prometheus-1818-by-mary-wollstonecraft-shelley/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 900,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1361000,
+ "grandparentRatingKey" : "3003",
+ "grandparentTitle" : "Mary Shelley",
+ "index" : 24,
+ "key" : "/library/metadata/32424",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-10-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3104",
+ "parentThumb" : "/mock/art/studio/3104/cover.png",
+ "parentTitle" : "Frankenstein",
+ "ratingKey" : "32424",
+ "studio" : "LibriVox",
+ "summary" : "Read by Cori Samuel.",
+ "title" : "Vol. III, Chapter III",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/frankenstein-or-the-modern-prometheus-1818-by-mary-wollstonecraft-shelley/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 900,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1399000,
+ "grandparentRatingKey" : "3003",
+ "grandparentTitle" : "Mary Shelley",
+ "index" : 25,
+ "key" : "/library/metadata/32425",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-10-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3104",
+ "parentThumb" : "/mock/art/studio/3104/cover.png",
+ "parentTitle" : "Frankenstein",
+ "ratingKey" : "32425",
+ "studio" : "LibriVox",
+ "summary" : "Read by Cori Samuel.",
+ "title" : "Vol. III, Chapter IV",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/frankenstein-or-the-modern-prometheus-1818-by-mary-wollstonecraft-shelley/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 900,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1274000,
+ "grandparentRatingKey" : "3003",
+ "grandparentTitle" : "Mary Shelley",
+ "index" : 26,
+ "key" : "/library/metadata/32426",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-10-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3104",
+ "parentThumb" : "/mock/art/studio/3104/cover.png",
+ "parentTitle" : "Frankenstein",
+ "ratingKey" : "32426",
+ "studio" : "LibriVox",
+ "summary" : "Read by Cori Samuel.",
+ "title" : "Vol. III, Chapter V",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/frankenstein-or-the-modern-prometheus-1818-by-mary-wollstonecraft-shelley/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 900,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 987000,
+ "grandparentRatingKey" : "3003",
+ "grandparentTitle" : "Mary Shelley",
+ "index" : 27,
+ "key" : "/library/metadata/32427",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-10-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3104",
+ "parentThumb" : "/mock/art/studio/3104/cover.png",
+ "parentTitle" : "Frankenstein",
+ "ratingKey" : "32427",
+ "studio" : "LibriVox",
+ "summary" : "Read by Cori Samuel.",
+ "title" : "Vol. III, Chapter VI",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/frankenstein-or-the-modern-prometheus-1818-by-mary-wollstonecraft-shelley/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 900,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1195000,
+ "grandparentRatingKey" : "3003",
+ "grandparentTitle" : "Mary Shelley",
+ "index" : 28,
+ "key" : "/library/metadata/32428",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-10-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3104",
+ "parentThumb" : "/mock/art/studio/3104/cover.png",
+ "parentTitle" : "Frankenstein",
+ "ratingKey" : "32428",
+ "studio" : "LibriVox",
+ "summary" : "Read by Cori Samuel.",
+ "title" : "Vol. III, Chapter VII",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/frankenstein-or-the-modern-prometheus-1818-by-mary-wollstonecraft-shelley/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 900,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 2125000,
+ "grandparentRatingKey" : "3003",
+ "grandparentTitle" : "Mary Shelley",
+ "index" : 29,
+ "key" : "/library/metadata/32429",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-10-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3104",
+ "parentThumb" : "/mock/art/studio/3104/cover.png",
+ "parentTitle" : "Frankenstein",
+ "ratingKey" : "32429",
+ "studio" : "LibriVox",
+ "summary" : "Read by Cori Samuel.",
+ "title" : "Vol. III, Letters",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/frankenstein-or-the-modern-prometheus-1818-by-mary-wollstonecraft-shelley/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "childCount" : 2,
+ "key" : "/library/metadata/3004/children",
+ "leafCount" : 39,
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "ratingKey" : "3004",
+ "summary" : "Robert Louis Stevenson (1850–1894), author of Treasure Island and The Strange Case of Dr. Jekyll and Mr. Hyde.",
+ "title" : "Robert Louis Stevenson",
+ "type" : "artist",
+ "viewedLeafCount" : 0
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120",
+ "https://librivox.org/the-strange-case-of-dr-jekyll-and-mr-hyde-by-robert-louis-stevenson/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "childCount" : 34,
+ "duration" : 27259000,
+ "key" : "/library/metadata/3105/children",
+ "leafCount" : 34,
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentRatingKey" : "3004",
+ "parentTitle" : "Robert Louis Stevenson",
+ "ratingKey" : "3105",
+ "studio" : "LibriVox",
+ "summary" : "Jim Hawkins discovers a treasure map and sails aboard the Hispaniola, where Long John Silver and his fellow pirates plot mutiny. Mark F. Smith reads Stevenson’s 1883 adventure novel in this fourth LibriVox version, cataloged in 2013.",
+ "thumb" : "/mock/art/studio/3105/cover.png",
+ "title" : "Treasure Island",
+ "titleSort" : "Treasure Island",
+ "type" : "album",
+ "viewedLeafCount" : 0,
+ "year" : 2013
+ },
+ "relatedIDs" : [
+ "3106"
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 952000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 1,
+ "key" : "/library/metadata/32501",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32501",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "Dedication and At the \"Admiral Benbow\"",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 899000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 2,
+ "key" : "/library/metadata/32502",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32502",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "Black Dog Appears and Disappears",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 851000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 3,
+ "key" : "/library/metadata/32503",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32503",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "The Black Spot",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 823000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 4,
+ "key" : "/library/metadata/32504",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32504",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "The Sea Chest",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 720000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 5,
+ "key" : "/library/metadata/32505",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32505",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "The Last of the Blind Man",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 810000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 6,
+ "key" : "/library/metadata/32506",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32506",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "The Captain's Papers",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 754000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 7,
+ "key" : "/library/metadata/32507",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32507",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "I Go to Bristol",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 719000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 8,
+ "key" : "/library/metadata/32508",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32508",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "At the Sign of the \"Spy-Glass\"",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 758000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 9,
+ "key" : "/library/metadata/32509",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32509",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "Powder and Arms",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 766000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 10,
+ "key" : "/library/metadata/32510",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32510",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "The Voyage",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 900000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 11,
+ "key" : "/library/metadata/32511",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32511",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "What I Heard in the Apple Barrel",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 769000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 12,
+ "key" : "/library/metadata/32512",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32512",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "Council of War",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 709000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 13,
+ "key" : "/library/metadata/32513",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32513",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "How My Shore Adventure Began",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 748000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 14,
+ "key" : "/library/metadata/32514",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32514",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "The First Blow",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 907000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 15,
+ "key" : "/library/metadata/32515",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32515",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "The Man of the Island",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 634000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 16,
+ "key" : "/library/metadata/32516",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32516",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "How the Ship Was Abandoned",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 569000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 17,
+ "key" : "/library/metadata/32517",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32517",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "The Jolly-Boat's Last Trip",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 615000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 18,
+ "key" : "/library/metadata/32518",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32518",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "End of the First Day's Fighting",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 778000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 19,
+ "key" : "/library/metadata/32519",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32519",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "The Garrison in the Stockade",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 764000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 20,
+ "key" : "/library/metadata/32520",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32520",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "Silver's Embassy",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 797000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 21,
+ "key" : "/library/metadata/32521",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32521",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "The Attack",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 792000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 22,
+ "key" : "/library/metadata/32522",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32522",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "How My Sea Adventure Began",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 627000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 23,
+ "key" : "/library/metadata/32523",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32523",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "The Ebb Tide Runs",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 782000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 24,
+ "key" : "/library/metadata/32524",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32524",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "The Cruise of the Coracle",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 689000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 25,
+ "key" : "/library/metadata/32525",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32525",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "I Strike the Jolly Roger",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 1150000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 26,
+ "key" : "/library/metadata/32526",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32526",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "Israel Hands",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 809000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 27,
+ "key" : "/library/metadata/32527",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32527",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "Pieces of Eight",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 1105000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 28,
+ "key" : "/library/metadata/32528",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32528",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "In the Enemy's Camp",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 890000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 29,
+ "key" : "/library/metadata/32529",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32529",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "The Black Spot Again",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 939000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 30,
+ "key" : "/library/metadata/32530",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32530",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "On Parole",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 890000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 31,
+ "key" : "/library/metadata/32531",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32531",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "The Treasure Hunt - Flint's Pointer",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 814000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 32,
+ "key" : "/library/metadata/32532",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32532",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "The Voice Among the Trees",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 790000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 33,
+ "key" : "/library/metadata/32533",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32533",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "The Fall of a Chieftain",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 1800,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "Nautical & Marine Fiction"
+ }
+ ],
+ "duration" : 740000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 34,
+ "key" : "/library/metadata/32534",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2013-07-28",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3105",
+ "parentThumb" : "/mock/art/studio/3105/cover.png",
+ "parentTitle" : "Treasure Island",
+ "ratingKey" : "32534",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "And Last",
+ "type" : "track",
+ "year" : 2013
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/treasure-island-version-4-by-robert-louis-stevenson/",
+ "https://www.gutenberg.org/ebooks/120"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 2700,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "childCount" : 1,
+ "key" : "/library/metadata/3005/children",
+ "leafCount" : 12,
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "ratingKey" : "3005",
+ "summary" : "Lewis Carroll (1832–1898), author of Alice's Adventures in Wonderland.",
+ "title" : "Lewis Carroll",
+ "type" : "artist",
+ "viewedLeafCount" : 0
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/alices-adventures-in-wonderland-version-7-by-lewis-carroll/",
+ "https://www.gutenberg.org/ebooks/11"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 2700,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Myths, Legends & Fairy Tales"
+ }
+ ],
+ "childCount" : 12,
+ "duration" : 11392000,
+ "key" : "/library/metadata/3106/children",
+ "leafCount" : 12,
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2020-05-30",
+ "parentRatingKey" : "3005",
+ "parentTitle" : "Lewis Carroll",
+ "ratingKey" : "3106",
+ "studio" : "LibriVox",
+ "summary" : "Alice follows a White Rabbit into a world of talking animals, impossible rules, and the Queen of Hearts. Craig Franklin reads Carroll’s 1865 novel in this seventh LibriVox version, cataloged in 2020.",
+ "thumb" : "/mock/art/studio/3106/cover.png",
+ "title" : "Alice's Adventures in Wonderland",
+ "titleSort" : "Alice's Adventures in Wonderland",
+ "type" : "album",
+ "viewedLeafCount" : 0,
+ "year" : 2020
+ },
+ "relatedIDs" : [
+ "3105"
+ ],
+ "sources" : [
+ "https://librivox.org/alices-adventures-in-wonderland-version-7-by-lewis-carroll/",
+ "https://www.gutenberg.org/ebooks/11"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 2700,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Myths, Legends & Fairy Tales"
+ }
+ ],
+ "duration" : 864000,
+ "grandparentRatingKey" : "3005",
+ "grandparentTitle" : "Lewis Carroll",
+ "index" : 1,
+ "key" : "/library/metadata/32601",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2020-05-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3106",
+ "parentThumb" : "/mock/art/studio/3106/cover.png",
+ "parentTitle" : "Alice's Adventures in Wonderland",
+ "ratingKey" : "32601",
+ "studio" : "LibriVox",
+ "summary" : "Read by Craig Franklin.",
+ "title" : "Down the Rabbit Hole",
+ "type" : "track",
+ "year" : 2020
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/alices-adventures-in-wonderland-version-7-by-lewis-carroll/",
+ "https://www.gutenberg.org/ebooks/11"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 2700,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Myths, Legends & Fairy Tales"
+ }
+ ],
+ "duration" : 881000,
+ "grandparentRatingKey" : "3005",
+ "grandparentTitle" : "Lewis Carroll",
+ "index" : 2,
+ "key" : "/library/metadata/32602",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2020-05-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3106",
+ "parentThumb" : "/mock/art/studio/3106/cover.png",
+ "parentTitle" : "Alice's Adventures in Wonderland",
+ "ratingKey" : "32602",
+ "studio" : "LibriVox",
+ "summary" : "Read by Craig Franklin.",
+ "title" : "The Pool of Tears",
+ "type" : "track",
+ "year" : 2020
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/alices-adventures-in-wonderland-version-7-by-lewis-carroll/",
+ "https://www.gutenberg.org/ebooks/11"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 2700,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Myths, Legends & Fairy Tales"
+ }
+ ],
+ "duration" : 728000,
+ "grandparentRatingKey" : "3005",
+ "grandparentTitle" : "Lewis Carroll",
+ "index" : 3,
+ "key" : "/library/metadata/32603",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2020-05-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3106",
+ "parentThumb" : "/mock/art/studio/3106/cover.png",
+ "parentTitle" : "Alice's Adventures in Wonderland",
+ "ratingKey" : "32603",
+ "studio" : "LibriVox",
+ "summary" : "Read by Craig Franklin.",
+ "title" : "A Caucus-Race and a Long Tale",
+ "type" : "track",
+ "year" : 2020
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/alices-adventures-in-wonderland-version-7-by-lewis-carroll/",
+ "https://www.gutenberg.org/ebooks/11"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 2700,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Myths, Legends & Fairy Tales"
+ }
+ ],
+ "duration" : 1077000,
+ "grandparentRatingKey" : "3005",
+ "grandparentTitle" : "Lewis Carroll",
+ "index" : 4,
+ "key" : "/library/metadata/32604",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2020-05-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3106",
+ "parentThumb" : "/mock/art/studio/3106/cover.png",
+ "parentTitle" : "Alice's Adventures in Wonderland",
+ "ratingKey" : "32604",
+ "studio" : "LibriVox",
+ "summary" : "Read by Craig Franklin.",
+ "title" : "The Rabbit Sends in a Little Bill",
+ "type" : "track",
+ "year" : 2020
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/alices-adventures-in-wonderland-version-7-by-lewis-carroll/",
+ "https://www.gutenberg.org/ebooks/11"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 2700,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Myths, Legends & Fairy Tales"
+ }
+ ],
+ "duration" : 955000,
+ "grandparentRatingKey" : "3005",
+ "grandparentTitle" : "Lewis Carroll",
+ "index" : 5,
+ "key" : "/library/metadata/32605",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2020-05-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3106",
+ "parentThumb" : "/mock/art/studio/3106/cover.png",
+ "parentTitle" : "Alice's Adventures in Wonderland",
+ "ratingKey" : "32605",
+ "studio" : "LibriVox",
+ "summary" : "Read by Craig Franklin.",
+ "title" : "Advice from a Caterpillar",
+ "type" : "track",
+ "year" : 2020
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/alices-adventures-in-wonderland-version-7-by-lewis-carroll/",
+ "https://www.gutenberg.org/ebooks/11"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 2700,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Myths, Legends & Fairy Tales"
+ }
+ ],
+ "duration" : 1059000,
+ "grandparentRatingKey" : "3005",
+ "grandparentTitle" : "Lewis Carroll",
+ "index" : 6,
+ "key" : "/library/metadata/32606",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2020-05-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3106",
+ "parentThumb" : "/mock/art/studio/3106/cover.png",
+ "parentTitle" : "Alice's Adventures in Wonderland",
+ "ratingKey" : "32606",
+ "studio" : "LibriVox",
+ "summary" : "Read by Craig Franklin.",
+ "title" : "Pig and Pepper",
+ "type" : "track",
+ "year" : 2020
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/alices-adventures-in-wonderland-version-7-by-lewis-carroll/",
+ "https://www.gutenberg.org/ebooks/11"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 2700,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Myths, Legends & Fairy Tales"
+ }
+ ],
+ "duration" : 1001000,
+ "grandparentRatingKey" : "3005",
+ "grandparentTitle" : "Lewis Carroll",
+ "index" : 7,
+ "key" : "/library/metadata/32607",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2020-05-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3106",
+ "parentThumb" : "/mock/art/studio/3106/cover.png",
+ "parentTitle" : "Alice's Adventures in Wonderland",
+ "ratingKey" : "32607",
+ "studio" : "LibriVox",
+ "summary" : "Read by Craig Franklin.",
+ "title" : "A Mad Tea-Party",
+ "type" : "track",
+ "year" : 2020
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/alices-adventures-in-wonderland-version-7-by-lewis-carroll/",
+ "https://www.gutenberg.org/ebooks/11"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 2700,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Myths, Legends & Fairy Tales"
+ }
+ ],
+ "duration" : 1051000,
+ "grandparentRatingKey" : "3005",
+ "grandparentTitle" : "Lewis Carroll",
+ "index" : 8,
+ "key" : "/library/metadata/32608",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2020-05-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3106",
+ "parentThumb" : "/mock/art/studio/3106/cover.png",
+ "parentTitle" : "Alice's Adventures in Wonderland",
+ "ratingKey" : "32608",
+ "studio" : "LibriVox",
+ "summary" : "Read by Craig Franklin.",
+ "title" : "The Queen's Croquet-Ground",
+ "type" : "track",
+ "year" : 2020
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/alices-adventures-in-wonderland-version-7-by-lewis-carroll/",
+ "https://www.gutenberg.org/ebooks/11"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 2700,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Myths, Legends & Fairy Tales"
+ }
+ ],
+ "duration" : 1043000,
+ "grandparentRatingKey" : "3005",
+ "grandparentTitle" : "Lewis Carroll",
+ "index" : 9,
+ "key" : "/library/metadata/32609",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2020-05-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3106",
+ "parentThumb" : "/mock/art/studio/3106/cover.png",
+ "parentTitle" : "Alice's Adventures in Wonderland",
+ "ratingKey" : "32609",
+ "studio" : "LibriVox",
+ "summary" : "Read by Craig Franklin.",
+ "title" : "The Mock Turtle's Story",
+ "type" : "track",
+ "year" : 2020
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/alices-adventures-in-wonderland-version-7-by-lewis-carroll/",
+ "https://www.gutenberg.org/ebooks/11"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 2700,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Myths, Legends & Fairy Tales"
+ }
+ ],
+ "duration" : 951000,
+ "grandparentRatingKey" : "3005",
+ "grandparentTitle" : "Lewis Carroll",
+ "index" : 10,
+ "key" : "/library/metadata/32610",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2020-05-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3106",
+ "parentThumb" : "/mock/art/studio/3106/cover.png",
+ "parentTitle" : "Alice's Adventures in Wonderland",
+ "ratingKey" : "32610",
+ "studio" : "LibriVox",
+ "summary" : "Read by Craig Franklin.",
+ "title" : "The Lobster Quadrille",
+ "type" : "track",
+ "year" : 2020
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/alices-adventures-in-wonderland-version-7-by-lewis-carroll/",
+ "https://www.gutenberg.org/ebooks/11"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 2700,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Myths, Legends & Fairy Tales"
+ }
+ ],
+ "duration" : 817000,
+ "grandparentRatingKey" : "3005",
+ "grandparentTitle" : "Lewis Carroll",
+ "index" : 11,
+ "key" : "/library/metadata/32611",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2020-05-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3106",
+ "parentThumb" : "/mock/art/studio/3106/cover.png",
+ "parentTitle" : "Alice's Adventures in Wonderland",
+ "ratingKey" : "32611",
+ "studio" : "LibriVox",
+ "summary" : "Read by Craig Franklin.",
+ "title" : "Who Stole the Tarts?",
+ "type" : "track",
+ "year" : 2020
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/alices-adventures-in-wonderland-version-7-by-lewis-carroll/",
+ "https://www.gutenberg.org/ebooks/11"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 2700,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Myths, Legends & Fairy Tales"
+ }
+ ],
+ "duration" : 965000,
+ "grandparentRatingKey" : "3005",
+ "grandparentTitle" : "Lewis Carroll",
+ "index" : 12,
+ "key" : "/library/metadata/32612",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2020-05-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3106",
+ "parentThumb" : "/mock/art/studio/3106/cover.png",
+ "parentTitle" : "Alice's Adventures in Wonderland",
+ "ratingKey" : "32612",
+ "studio" : "LibriVox",
+ "summary" : "Read by Craig Franklin.",
+ "title" : "Alice's Evidence",
+ "type" : "track",
+ "year" : 2020
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/alices-adventures-in-wonderland-version-7-by-lewis-carroll/",
+ "https://www.gutenberg.org/ebooks/11"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 3600,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "childCount" : 1,
+ "key" : "/library/metadata/3006/children",
+ "leafCount" : 12,
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "ratingKey" : "3006",
+ "summary" : "Arthur Conan Doyle (1859–1930), author of The Adventures of Sherlock Holmes.",
+ "title" : "Arthur Conan Doyle",
+ "type" : "artist",
+ "viewedLeafCount" : 0
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-adventures-of-sherlock-holmes-by-sir-arthur-conan-doyle-2/",
+ "https://www.gutenberg.org/ebooks/1661"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 3600,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "General Fiction"
+ },
+ {
+ "tag" : "Detective Fiction"
+ }
+ ],
+ "childCount" : 12,
+ "duration" : 40483000,
+ "key" : "/library/metadata/3107/children",
+ "leafCount" : 12,
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2010-07-31",
+ "parentRatingKey" : "3006",
+ "parentTitle" : "Arthur Conan Doyle",
+ "ratingKey" : "3107",
+ "studio" : "LibriVox",
+ "summary" : "Dr. Watson recounts twelve cases solved by Sherlock Holmes, from A Scandal in Bohemia to The Adventure of the Copper Beeches. Mark F. Smith reads this 1892 collection in the third LibriVox version, cataloged in 2010.",
+ "thumb" : "/mock/art/studio/3107/cover.png",
+ "title" : "The Adventures of Sherlock Holmes",
+ "titleSort" : "Adventures of Sherlock Holmes",
+ "type" : "album",
+ "viewedLeafCount" : 0,
+ "year" : 2010
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-adventures-of-sherlock-holmes-by-sir-arthur-conan-doyle-2/",
+ "https://www.gutenberg.org/ebooks/1661"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 3600,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "General Fiction"
+ },
+ {
+ "tag" : "Detective Fiction"
+ }
+ ],
+ "duration" : 3474000,
+ "grandparentRatingKey" : "3006",
+ "grandparentTitle" : "Arthur Conan Doyle",
+ "index" : 1,
+ "key" : "/library/metadata/32701",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2010-07-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3107",
+ "parentThumb" : "/mock/art/studio/3107/cover.png",
+ "parentTitle" : "The Adventures of Sherlock Holmes",
+ "ratingKey" : "32701",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "A Scandal in Bohemia",
+ "type" : "track",
+ "year" : 2010
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-adventures-of-sherlock-holmes-by-sir-arthur-conan-doyle-2/",
+ "https://www.gutenberg.org/ebooks/1661"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 3600,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "General Fiction"
+ },
+ {
+ "tag" : "Detective Fiction"
+ }
+ ],
+ "duration" : 3600000,
+ "grandparentRatingKey" : "3006",
+ "grandparentTitle" : "Arthur Conan Doyle",
+ "index" : 2,
+ "key" : "/library/metadata/32702",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2010-07-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3107",
+ "parentThumb" : "/mock/art/studio/3107/cover.png",
+ "parentTitle" : "The Adventures of Sherlock Holmes",
+ "ratingKey" : "32702",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "The Red-Headed League",
+ "type" : "track",
+ "year" : 2010
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-adventures-of-sherlock-holmes-by-sir-arthur-conan-doyle-2/",
+ "https://www.gutenberg.org/ebooks/1661"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 3600,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "General Fiction"
+ },
+ {
+ "tag" : "Detective Fiction"
+ }
+ ],
+ "duration" : 2718000,
+ "grandparentRatingKey" : "3006",
+ "grandparentTitle" : "Arthur Conan Doyle",
+ "index" : 3,
+ "key" : "/library/metadata/32703",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2010-07-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3107",
+ "parentThumb" : "/mock/art/studio/3107/cover.png",
+ "parentTitle" : "The Adventures of Sherlock Holmes",
+ "ratingKey" : "32703",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "A Case of Identity",
+ "type" : "track",
+ "year" : 2010
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-adventures-of-sherlock-holmes-by-sir-arthur-conan-doyle-2/",
+ "https://www.gutenberg.org/ebooks/1661"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 3600,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "General Fiction"
+ },
+ {
+ "tag" : "Detective Fiction"
+ }
+ ],
+ "duration" : 3778000,
+ "grandparentRatingKey" : "3006",
+ "grandparentTitle" : "Arthur Conan Doyle",
+ "index" : 4,
+ "key" : "/library/metadata/32704",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2010-07-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3107",
+ "parentThumb" : "/mock/art/studio/3107/cover.png",
+ "parentTitle" : "The Adventures of Sherlock Holmes",
+ "ratingKey" : "32704",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "The Boscombe Valley Mystery",
+ "type" : "track",
+ "year" : 2010
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-adventures-of-sherlock-holmes-by-sir-arthur-conan-doyle-2/",
+ "https://www.gutenberg.org/ebooks/1661"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 3600,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "General Fiction"
+ },
+ {
+ "tag" : "Detective Fiction"
+ }
+ ],
+ "duration" : 2810000,
+ "grandparentRatingKey" : "3006",
+ "grandparentTitle" : "Arthur Conan Doyle",
+ "index" : 5,
+ "key" : "/library/metadata/32705",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2010-07-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3107",
+ "parentThumb" : "/mock/art/studio/3107/cover.png",
+ "parentTitle" : "The Adventures of Sherlock Holmes",
+ "ratingKey" : "32705",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "The Five Orange Pips",
+ "type" : "track",
+ "year" : 2010
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-adventures-of-sherlock-holmes-by-sir-arthur-conan-doyle-2/",
+ "https://www.gutenberg.org/ebooks/1661"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 3600,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "General Fiction"
+ },
+ {
+ "tag" : "Detective Fiction"
+ }
+ ],
+ "duration" : 3585000,
+ "grandparentRatingKey" : "3006",
+ "grandparentTitle" : "Arthur Conan Doyle",
+ "index" : 6,
+ "key" : "/library/metadata/32706",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2010-07-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3107",
+ "parentThumb" : "/mock/art/studio/3107/cover.png",
+ "parentTitle" : "The Adventures of Sherlock Holmes",
+ "ratingKey" : "32706",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "The Man with the Twisted Lip",
+ "type" : "track",
+ "year" : 2010
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-adventures-of-sherlock-holmes-by-sir-arthur-conan-doyle-2/",
+ "https://www.gutenberg.org/ebooks/1661"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 3600,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "General Fiction"
+ },
+ {
+ "tag" : "Detective Fiction"
+ }
+ ],
+ "duration" : 3101000,
+ "grandparentRatingKey" : "3006",
+ "grandparentTitle" : "Arthur Conan Doyle",
+ "index" : 7,
+ "key" : "/library/metadata/32707",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2010-07-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3107",
+ "parentThumb" : "/mock/art/studio/3107/cover.png",
+ "parentTitle" : "The Adventures of Sherlock Holmes",
+ "ratingKey" : "32707",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "The Adventure of the Blue Carbuncle",
+ "type" : "track",
+ "year" : 2010
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-adventures-of-sherlock-holmes-by-sir-arthur-conan-doyle-2/",
+ "https://www.gutenberg.org/ebooks/1661"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 3600,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "General Fiction"
+ },
+ {
+ "tag" : "Detective Fiction"
+ }
+ ],
+ "duration" : 3767000,
+ "grandparentRatingKey" : "3006",
+ "grandparentTitle" : "Arthur Conan Doyle",
+ "index" : 8,
+ "key" : "/library/metadata/32708",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2010-07-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3107",
+ "parentThumb" : "/mock/art/studio/3107/cover.png",
+ "parentTitle" : "The Adventures of Sherlock Holmes",
+ "ratingKey" : "32708",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "The Adventure of the Speckled Band",
+ "type" : "track",
+ "year" : 2010
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-adventures-of-sherlock-holmes-by-sir-arthur-conan-doyle-2/",
+ "https://www.gutenberg.org/ebooks/1661"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 3600,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "General Fiction"
+ },
+ {
+ "tag" : "Detective Fiction"
+ }
+ ],
+ "duration" : 3169000,
+ "grandparentRatingKey" : "3006",
+ "grandparentTitle" : "Arthur Conan Doyle",
+ "index" : 9,
+ "key" : "/library/metadata/32709",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2010-07-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3107",
+ "parentThumb" : "/mock/art/studio/3107/cover.png",
+ "parentTitle" : "The Adventures of Sherlock Holmes",
+ "ratingKey" : "32709",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "The Adventure of the Engineer's Thumb",
+ "type" : "track",
+ "year" : 2010
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-adventures-of-sherlock-holmes-by-sir-arthur-conan-doyle-2/",
+ "https://www.gutenberg.org/ebooks/1661"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 3600,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "General Fiction"
+ },
+ {
+ "tag" : "Detective Fiction"
+ }
+ ],
+ "duration" : 3156000,
+ "grandparentRatingKey" : "3006",
+ "grandparentTitle" : "Arthur Conan Doyle",
+ "index" : 10,
+ "key" : "/library/metadata/32710",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2010-07-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3107",
+ "parentThumb" : "/mock/art/studio/3107/cover.png",
+ "parentTitle" : "The Adventures of Sherlock Holmes",
+ "ratingKey" : "32710",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "The Adventure of the Noble Bachelor",
+ "type" : "track",
+ "year" : 2010
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-adventures-of-sherlock-holmes-by-sir-arthur-conan-doyle-2/",
+ "https://www.gutenberg.org/ebooks/1661"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 3600,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "General Fiction"
+ },
+ {
+ "tag" : "Detective Fiction"
+ }
+ ],
+ "duration" : 3560000,
+ "grandparentRatingKey" : "3006",
+ "grandparentTitle" : "Arthur Conan Doyle",
+ "index" : 11,
+ "key" : "/library/metadata/32711",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2010-07-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3107",
+ "parentThumb" : "/mock/art/studio/3107/cover.png",
+ "parentTitle" : "The Adventures of Sherlock Holmes",
+ "ratingKey" : "32711",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "The Adventure of the Beryl Coronet",
+ "type" : "track",
+ "year" : 2010
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-adventures-of-sherlock-holmes-by-sir-arthur-conan-doyle-2/",
+ "https://www.gutenberg.org/ebooks/1661"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 3600,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure Fiction"
+ },
+ {
+ "tag" : "General Fiction"
+ },
+ {
+ "tag" : "Detective Fiction"
+ }
+ ],
+ "duration" : 3765000,
+ "grandparentRatingKey" : "3006",
+ "grandparentTitle" : "Arthur Conan Doyle",
+ "index" : 12,
+ "key" : "/library/metadata/32712",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2010-07-31",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3107",
+ "parentThumb" : "/mock/art/studio/3107/cover.png",
+ "parentTitle" : "The Adventures of Sherlock Holmes",
+ "ratingKey" : "32712",
+ "studio" : "LibriVox",
+ "summary" : "Read by Mark F. Smith.",
+ "title" : "The Adventure of the Copper Beeches",
+ "type" : "track",
+ "year" : 2010
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-adventures-of-sherlock-holmes-by-sir-arthur-conan-doyle-2/",
+ "https://www.gutenberg.org/ebooks/1661"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "childCount" : 1,
+ "key" : "/library/metadata/3007/children",
+ "leafCount" : 119,
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "ratingKey" : "3007",
+ "summary" : "Alexandre Dumas (1802–1870), author of The Count of Monte Cristo and The Three Musketeers.",
+ "title" : "Alexandre Dumas",
+ "type" : "artist",
+ "viewedLeafCount" : 0
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "childCount" : 119,
+ "duration" : 180217000,
+ "key" : "/library/metadata/3108/children",
+ "leafCount" : 119,
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentRatingKey" : "3007",
+ "parentTitle" : "Alexandre Dumas",
+ "ratingKey" : "3108",
+ "studio" : "LibriVox",
+ "summary" : "Wrongfully imprisoned, Edmond Dantès escapes and returns under a new identity to pursue the people who betrayed him. This 2007 LibriVox edition uses the original volunteer readings, with split chapters retained and alternate recordings excluded.",
+ "thumb" : "/mock/art/studio/3108/cover.png",
+ "title" : "The Count of Monte Cristo",
+ "titleSort" : "Count of Monte Cristo",
+ "type" : "album",
+ "viewedLeafCount" : 0,
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1179000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 1,
+ "key" : "/library/metadata/328001",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328001",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "Marseilles--The Arrival",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1157000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 2,
+ "key" : "/library/metadata/328002",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328002",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "Father and Son",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1898000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 3,
+ "key" : "/library/metadata/328003",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328003",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Catalans",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1132000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 4,
+ "key" : "/library/metadata/328004",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328004",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "Conspiracy",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 2013000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 5,
+ "key" : "/library/metadata/328005",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328005",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Marriage-Feast",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1563000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 6,
+ "key" : "/library/metadata/328006",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328006",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Deputy Procureur du Roi",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1284000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 7,
+ "key" : "/library/metadata/328007",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328007",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Examination",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1274000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 8,
+ "key" : "/library/metadata/328008",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328008",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Chateau D'If",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 658000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 9,
+ "key" : "/library/metadata/328009",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328009",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Evening of the Betrothal",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1083000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 10,
+ "key" : "/library/metadata/328010",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328010",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The King's Closet at the Tuileries",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1251000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 11,
+ "key" : "/library/metadata/328011",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328011",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Corsican Ogre",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1019000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 12,
+ "key" : "/library/metadata/328012",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328012",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "Father and Son",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1005000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 13,
+ "key" : "/library/metadata/328013",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328013",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Hundred Days",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1339000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 14,
+ "key" : "/library/metadata/328014",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328014",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Two Prisoners",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1983000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 15,
+ "key" : "/library/metadata/328015",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328015",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "Number 34 and Number 27",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1688000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 16,
+ "key" : "/library/metadata/328016",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328016",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "A Learned Italian",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 3033000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 17,
+ "key" : "/library/metadata/328017",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328017",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Abbe's Chamber",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1745000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 18,
+ "key" : "/library/metadata/328018",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328018",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Treasure",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1383000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 19,
+ "key" : "/library/metadata/328019",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328019",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Third Attack",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 762000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 20,
+ "key" : "/library/metadata/328020",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328020",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Cemetary of Chateau D'If",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1417000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 21,
+ "key" : "/library/metadata/328021",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328021",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Island of Tiboulen",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1104000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 22,
+ "key" : "/library/metadata/328022",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328022",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Smugglers",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1176000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 23,
+ "key" : "/library/metadata/328023",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328023",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Island of Monte Cristo",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1053000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 24,
+ "key" : "/library/metadata/328024",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328024",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Secret Cave",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1256000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 25,
+ "key" : "/library/metadata/328025",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328025",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Unknown",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 2296000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 26,
+ "key" : "/library/metadata/328026",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328026",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Pont du Gard Inn",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1711000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 27,
+ "key" : "/library/metadata/328027",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328027",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Story",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 933000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 28,
+ "key" : "/library/metadata/328028",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328028",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "028: The Prison Register",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1767000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 29,
+ "key" : "/library/metadata/328029",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328029",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The House of Morrel & Son",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 2190000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 30,
+ "key" : "/library/metadata/328030",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328030",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Fifth of September",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 3441000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 31,
+ "key" : "/library/metadata/328031",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328031",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "Italy: Sinbad the Sailor",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 884000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 32,
+ "key" : "/library/metadata/328032",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328032",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Waking",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 4161000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 33,
+ "key" : "/library/metadata/328033",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328033",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "Roman Bandits",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 2121000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 34,
+ "key" : "/library/metadata/328034",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328034",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "034a - The Colosseum",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 2096000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 35,
+ "key" : "/library/metadata/328035",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328035",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "034b - The Colosseum",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1842000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 36,
+ "key" : "/library/metadata/328036",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328036",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "La Mazzolata",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 2338000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 37,
+ "key" : "/library/metadata/328037",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328037",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Carnival at Rome",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 2109000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 38,
+ "key" : "/library/metadata/328038",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328038",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Catacombs of Saint Sebastian",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1158000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 39,
+ "key" : "/library/metadata/328039",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328039",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Compact",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1051000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 40,
+ "key" : "/library/metadata/328040",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328040",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Guest",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 2895000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 41,
+ "key" : "/library/metadata/328041",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328041",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Breakfast",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1685000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 42,
+ "key" : "/library/metadata/328042",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328042",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Presentation",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 556000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 43,
+ "key" : "/library/metadata/328043",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328043",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The House at Auteuil",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 971000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 44,
+ "key" : "/library/metadata/328044",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328044",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The House at Auteuil",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 3251000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 45,
+ "key" : "/library/metadata/328045",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328045",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Vendetta",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 2080000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 46,
+ "key" : "/library/metadata/328046",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328046",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Rain of Blood",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1959000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 47,
+ "key" : "/library/metadata/328047",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328047",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "Unlimited Credit",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1942000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 48,
+ "key" : "/library/metadata/328048",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328048",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Dappled Greys",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1823000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 49,
+ "key" : "/library/metadata/328049",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328049",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Ideology",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 834000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 50,
+ "key" : "/library/metadata/328050",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328050",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "Haidee",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1117000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 51,
+ "key" : "/library/metadata/328051",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328051",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Morrel Family",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1717000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 52,
+ "key" : "/library/metadata/328052",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328052",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "Pyramus and Thisbe",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 2189000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 53,
+ "key" : "/library/metadata/328053",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328053",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "Toxicology",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 2164000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 54,
+ "key" : "/library/metadata/328054",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328054",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "Robert le Diable",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1391000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 55,
+ "key" : "/library/metadata/328055",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328055",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "A Flurry in Stocks",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1269000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 56,
+ "key" : "/library/metadata/328056",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328056",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "Major Cavalcanti",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1486000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 57,
+ "key" : "/library/metadata/328057",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328057",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "056 Andrea Cavalcanti",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1608000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 58,
+ "key" : "/library/metadata/328058",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328058",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "In the Lucerne Patch",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1649000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 59,
+ "key" : "/library/metadata/328059",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328059",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "M. Noirtier de Villefort",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1609000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 60,
+ "key" : "/library/metadata/328060",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328060",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Will",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1742000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 61,
+ "key" : "/library/metadata/328061",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328061",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Telegraph",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1293000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 62,
+ "key" : "/library/metadata/328062",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328062",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "How a Gardener...",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1303000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 63,
+ "key" : "/library/metadata/328063",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328063",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "Ghosts",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1311000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 64,
+ "key" : "/library/metadata/328064",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328064",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Dinner",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1423000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 65,
+ "key" : "/library/metadata/328065",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328065",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Beggar",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1411000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 66,
+ "key" : "/library/metadata/328066",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328066",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "A Conjugal Scene",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1413000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 67,
+ "key" : "/library/metadata/328067",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328067",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "Matrimonial Projects",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1504000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 68,
+ "key" : "/library/metadata/328068",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328068",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "At the Office of the King's Attorney",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1027000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 69,
+ "key" : "/library/metadata/328069",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328069",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "A Summer Ball",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1348000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 70,
+ "key" : "/library/metadata/328070",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328070",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Inquiry",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1294000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 71,
+ "key" : "/library/metadata/328071",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328071",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Ball",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 528000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 72,
+ "key" : "/library/metadata/328072",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328072",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "Bread and Salt",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1771000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 73,
+ "key" : "/library/metadata/328073",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328073",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "Madame de Saint-Meran",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1691000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 74,
+ "key" : "/library/metadata/328074",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328074",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Promise, part 1",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1490000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 75,
+ "key" : "/library/metadata/328075",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328075",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Promise, part 2",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1418000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 76,
+ "key" : "/library/metadata/328076",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328076",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Villefort Family Vault",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1685000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 77,
+ "key" : "/library/metadata/328077",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328077",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "A Signed Statement",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1580000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 78,
+ "key" : "/library/metadata/328078",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328078",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Progress of Cavalcanti the Younger",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 3405000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 79,
+ "key" : "/library/metadata/328079",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328079",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "Haidee",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 2348000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 80,
+ "key" : "/library/metadata/328080",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328080",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "We Hear from Yanina",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1535000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 81,
+ "key" : "/library/metadata/328081",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328081",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Lemonade",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 743000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 82,
+ "key" : "/library/metadata/328082",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328082",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Accusation",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1922000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 83,
+ "key" : "/library/metadata/328083",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328083",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Room of the Retired Baker",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1602000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 84,
+ "key" : "/library/metadata/328084",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328084",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Burglary",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 795000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 85,
+ "key" : "/library/metadata/328085",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328085",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Hand of God",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 827000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 86,
+ "key" : "/library/metadata/328086",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328086",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "Beauchamp",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1194000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 87,
+ "key" : "/library/metadata/328087",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328087",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Journey",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1648000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 88,
+ "key" : "/library/metadata/328088",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328088",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Trial",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 818000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 89,
+ "key" : "/library/metadata/328089",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328089",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Challenge",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1181000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 90,
+ "key" : "/library/metadata/328090",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328090",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Insult",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1428000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 91,
+ "key" : "/library/metadata/328091",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328091",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "089 A Nocturnal Visit",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 2228000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 92,
+ "key" : "/library/metadata/328092",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328092",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Meeting",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1234000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 93,
+ "key" : "/library/metadata/328093",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328093",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "Mother and Son",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1018000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 94,
+ "key" : "/library/metadata/328094",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328094",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Suicide",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 884000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 95,
+ "key" : "/library/metadata/328095",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328095",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "Valentine",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1745000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 96,
+ "key" : "/library/metadata/328096",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328096",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "Maximilian's Avowal",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1480000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 97,
+ "key" : "/library/metadata/328097",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328097",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "Father and Daughter",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1250000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 98,
+ "key" : "/library/metadata/328098",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328098",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Contract",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 736000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 99,
+ "key" : "/library/metadata/328099",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328099",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Departure for Belgium",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1486000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 100,
+ "key" : "/library/metadata/328100",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328100",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Bell and Bottle Tavern",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1326000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 101,
+ "key" : "/library/metadata/328101",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328101",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Law",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 961000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 102,
+ "key" : "/library/metadata/328102",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328102",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Apparition",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 799000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 103,
+ "key" : "/library/metadata/328103",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328103",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "Locusta",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 682000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 104,
+ "key" : "/library/metadata/328104",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328104",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "Valentine",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 965000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 105,
+ "key" : "/library/metadata/328105",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328105",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "Maximilian",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1352000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 106,
+ "key" : "/library/metadata/328106",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328106",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "Danglars Signature",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1826000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 107,
+ "key" : "/library/metadata/328107",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328107",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Cemetery of Pere-la-Chaise",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 2034000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 108,
+ "key" : "/library/metadata/328108",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328108",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "Dividing the Proceeds",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 878000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 109,
+ "key" : "/library/metadata/328109",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328109",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Lions' Den",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1443000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 110,
+ "key" : "/library/metadata/328110",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328110",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Judge",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 784000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 111,
+ "key" : "/library/metadata/328111",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328111",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Assizes",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 863000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 112,
+ "key" : "/library/metadata/328112",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328112",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Indictment",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1405000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 113,
+ "key" : "/library/metadata/328113",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328113",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "Expiation",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 2335000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 114,
+ "key" : "/library/metadata/328114",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328114",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Departure",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1871000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 115,
+ "key" : "/library/metadata/328115",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328115",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Past",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1718000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 116,
+ "key" : "/library/metadata/328116",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328116",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "Peppino",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 844000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 117,
+ "key" : "/library/metadata/328117",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328117",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "Luigi Vampa's Bill of Fare",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 696000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 118,
+ "key" : "/library/metadata/328118",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328118",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Pardon",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Action & Adventure"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1948000,
+ "grandparentRatingKey" : "3007",
+ "grandparentTitle" : "Alexandre Dumas",
+ "index" : 119,
+ "key" : "/library/metadata/328119",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2007-11-30",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3108",
+ "parentThumb" : "/mock/art/studio/3108/cover.png",
+ "parentTitle" : "The Count of Monte Cristo",
+ "ratingKey" : "328119",
+ "studio" : "LibriVox",
+ "summary" : "Read by LibriVox volunteers.",
+ "title" : "The Fifth of October",
+ "type" : "track",
+ "year" : 2007
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-count-of-monte-cristo-by-alexandre-dumas/",
+ "https://archive.org/details/count_monte_cristo_0711_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "childCount" : 1,
+ "key" : "/library/metadata/3008/children",
+ "leafCount" : 13,
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "ratingKey" : "3008",
+ "summary" : "Oscar Wilde (1854–1900), author of The Picture of Dorian Gray.",
+ "title" : "Oscar Wilde",
+ "type" : "artist",
+ "viewedLeafCount" : 0
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-picture-of-dorian-gray-by-oscar-wilde/",
+ "https://archive.org/details/dorian_gray_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "childCount" : 13,
+ "duration" : 22292000,
+ "key" : "/library/metadata/3109/children",
+ "leafCount" : 13,
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2006-08-04",
+ "parentRatingKey" : "3008",
+ "parentTitle" : "Oscar Wilde",
+ "ratingKey" : "3109",
+ "studio" : "LibriVox",
+ "summary" : "Dorian Gray remains young while his portrait bears the marks of his corruption. John Gonzalez narrates this 13-chapter LibriVox edition, released in 2006.",
+ "thumb" : "/mock/art/studio/3109/cover.png",
+ "title" : "The Picture of Dorian Gray",
+ "titleSort" : "Picture of Dorian Gray",
+ "type" : "album",
+ "viewedLeafCount" : 0,
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-picture-of-dorian-gray-by-oscar-wilde/",
+ "https://archive.org/details/dorian_gray_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 2131000,
+ "grandparentRatingKey" : "3008",
+ "grandparentTitle" : "Oscar Wilde",
+ "index" : 1,
+ "key" : "/library/metadata/329001",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2006-08-04",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3109",
+ "parentThumb" : "/mock/art/studio/3109/cover.png",
+ "parentTitle" : "The Picture of Dorian Gray",
+ "ratingKey" : "329001",
+ "studio" : "LibriVox",
+ "summary" : "Read by John Gonzalez.",
+ "title" : "Chapter 01",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-picture-of-dorian-gray-by-oscar-wilde/",
+ "https://archive.org/details/dorian_gray_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 2304000,
+ "grandparentRatingKey" : "3008",
+ "grandparentTitle" : "Oscar Wilde",
+ "index" : 2,
+ "key" : "/library/metadata/329002",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2006-08-04",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3109",
+ "parentThumb" : "/mock/art/studio/3109/cover.png",
+ "parentTitle" : "The Picture of Dorian Gray",
+ "ratingKey" : "329002",
+ "studio" : "LibriVox",
+ "summary" : "Read by John Gonzalez.",
+ "title" : "Chapter 02",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-picture-of-dorian-gray-by-oscar-wilde/",
+ "https://archive.org/details/dorian_gray_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 2133000,
+ "grandparentRatingKey" : "3008",
+ "grandparentTitle" : "Oscar Wilde",
+ "index" : 3,
+ "key" : "/library/metadata/329003",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2006-08-04",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3109",
+ "parentThumb" : "/mock/art/studio/3109/cover.png",
+ "parentTitle" : "The Picture of Dorian Gray",
+ "ratingKey" : "329003",
+ "studio" : "LibriVox",
+ "summary" : "Read by John Gonzalez.",
+ "title" : "Chapter 03",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-picture-of-dorian-gray-by-oscar-wilde/",
+ "https://archive.org/details/dorian_gray_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 880000,
+ "grandparentRatingKey" : "3008",
+ "grandparentTitle" : "Oscar Wilde",
+ "index" : 4,
+ "key" : "/library/metadata/329004",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2006-08-04",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3109",
+ "parentThumb" : "/mock/art/studio/3109/cover.png",
+ "parentTitle" : "The Picture of Dorian Gray",
+ "ratingKey" : "329004",
+ "studio" : "LibriVox",
+ "summary" : "Read by John Gonzalez.",
+ "title" : "Chapter 04",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-picture-of-dorian-gray-by-oscar-wilde/",
+ "https://archive.org/details/dorian_gray_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1623000,
+ "grandparentRatingKey" : "3008",
+ "grandparentTitle" : "Oscar Wilde",
+ "index" : 5,
+ "key" : "/library/metadata/329005",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2006-08-04",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3109",
+ "parentThumb" : "/mock/art/studio/3109/cover.png",
+ "parentTitle" : "The Picture of Dorian Gray",
+ "ratingKey" : "329005",
+ "studio" : "LibriVox",
+ "summary" : "Read by John Gonzalez.",
+ "title" : "Chapter 05",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-picture-of-dorian-gray-by-oscar-wilde/",
+ "https://archive.org/details/dorian_gray_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 2106000,
+ "grandparentRatingKey" : "3008",
+ "grandparentTitle" : "Oscar Wilde",
+ "index" : 6,
+ "key" : "/library/metadata/329006",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2006-08-04",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3109",
+ "parentThumb" : "/mock/art/studio/3109/cover.png",
+ "parentTitle" : "The Picture of Dorian Gray",
+ "ratingKey" : "329006",
+ "studio" : "LibriVox",
+ "summary" : "Read by John Gonzalez.",
+ "title" : "Chapter 06",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-picture-of-dorian-gray-by-oscar-wilde/",
+ "https://archive.org/details/dorian_gray_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1465000,
+ "grandparentRatingKey" : "3008",
+ "grandparentTitle" : "Oscar Wilde",
+ "index" : 7,
+ "key" : "/library/metadata/329007",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2006-08-04",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3109",
+ "parentThumb" : "/mock/art/studio/3109/cover.png",
+ "parentTitle" : "The Picture of Dorian Gray",
+ "ratingKey" : "329007",
+ "studio" : "LibriVox",
+ "summary" : "Read by John Gonzalez.",
+ "title" : "Chapter 07",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-picture-of-dorian-gray-by-oscar-wilde/",
+ "https://archive.org/details/dorian_gray_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1299000,
+ "grandparentRatingKey" : "3008",
+ "grandparentTitle" : "Oscar Wilde",
+ "index" : 8,
+ "key" : "/library/metadata/329008",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2006-08-04",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3109",
+ "parentThumb" : "/mock/art/studio/3109/cover.png",
+ "parentTitle" : "The Picture of Dorian Gray",
+ "ratingKey" : "329008",
+ "studio" : "LibriVox",
+ "summary" : "Read by John Gonzalez.",
+ "title" : "Chapter 08",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-picture-of-dorian-gray-by-oscar-wilde/",
+ "https://archive.org/details/dorian_gray_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 3126000,
+ "grandparentRatingKey" : "3008",
+ "grandparentTitle" : "Oscar Wilde",
+ "index" : 9,
+ "key" : "/library/metadata/329009",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2006-08-04",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3109",
+ "parentThumb" : "/mock/art/studio/3109/cover.png",
+ "parentTitle" : "The Picture of Dorian Gray",
+ "ratingKey" : "329009",
+ "studio" : "LibriVox",
+ "summary" : "Read by John Gonzalez.",
+ "title" : "Chapter 09",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-picture-of-dorian-gray-by-oscar-wilde/",
+ "https://archive.org/details/dorian_gray_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 914000,
+ "grandparentRatingKey" : "3008",
+ "grandparentTitle" : "Oscar Wilde",
+ "index" : 10,
+ "key" : "/library/metadata/329010",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2006-08-04",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3109",
+ "parentThumb" : "/mock/art/studio/3109/cover.png",
+ "parentTitle" : "The Picture of Dorian Gray",
+ "ratingKey" : "329010",
+ "studio" : "LibriVox",
+ "summary" : "Read by John Gonzalez.",
+ "title" : "Chapter 10",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-picture-of-dorian-gray-by-oscar-wilde/",
+ "https://archive.org/details/dorian_gray_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 948000,
+ "grandparentRatingKey" : "3008",
+ "grandparentTitle" : "Oscar Wilde",
+ "index" : 11,
+ "key" : "/library/metadata/329011",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2006-08-04",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3109",
+ "parentThumb" : "/mock/art/studio/3109/cover.png",
+ "parentTitle" : "The Picture of Dorian Gray",
+ "ratingKey" : "329011",
+ "studio" : "LibriVox",
+ "summary" : "Read by John Gonzalez.",
+ "title" : "Chapter 11",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-picture-of-dorian-gray-by-oscar-wilde/",
+ "https://archive.org/details/dorian_gray_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1751000,
+ "grandparentRatingKey" : "3008",
+ "grandparentTitle" : "Oscar Wilde",
+ "index" : 12,
+ "key" : "/library/metadata/329012",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2006-08-04",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3109",
+ "parentThumb" : "/mock/art/studio/3109/cover.png",
+ "parentTitle" : "The Picture of Dorian Gray",
+ "ratingKey" : "329012",
+ "studio" : "LibriVox",
+ "summary" : "Read by John Gonzalez.",
+ "title" : "Chapter 12",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-picture-of-dorian-gray-by-oscar-wilde/",
+ "https://archive.org/details/dorian_gray_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ },
+ {
+ "tag" : "Literary Fiction"
+ }
+ ],
+ "duration" : 1612000,
+ "grandparentRatingKey" : "3008",
+ "grandparentTitle" : "Oscar Wilde",
+ "index" : 13,
+ "key" : "/library/metadata/329013",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2006-08-04",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3109",
+ "parentThumb" : "/mock/art/studio/3109/cover.png",
+ "parentTitle" : "The Picture of Dorian Gray",
+ "ratingKey" : "329013",
+ "studio" : "LibriVox",
+ "summary" : "Read by John Gonzalez.",
+ "title" : "Chapter 13",
+ "type" : "track",
+ "year" : 2006
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-picture-of-dorian-gray-by-oscar-wilde/",
+ "https://archive.org/details/dorian_gray_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "childCount" : 5,
+ "duration" : 11514000,
+ "key" : "/library/metadata/3110/children",
+ "leafCount" : 5,
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-11-07",
+ "parentRatingKey" : "3004",
+ "parentTitle" : "Robert Louis Stevenson",
+ "ratingKey" : "3110",
+ "studio" : "LibriVox",
+ "summary" : "A London lawyer investigates the disturbing connection between his friend Dr. Jekyll and the violent Edward Hyde. Bob Neufeld narrates this 2011 LibriVox edition in five parts.",
+ "thumb" : "/mock/art/studio/3110/cover.png",
+ "title" : "The Strange Case of Dr. Jekyll and Mr. Hyde",
+ "titleSort" : "Strange Case of Dr. Jekyll and Mr. Hyde",
+ "type" : "album",
+ "viewedLeafCount" : 0,
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-strange-case-of-dr-jekyll-and-mr-hyde-by-robert-louis-stevenson/",
+ "https://archive.org/details/jekyll_hyde_1111_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "duration" : 2677000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 1,
+ "key" : "/library/metadata/330001",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-11-07",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3110",
+ "parentThumb" : "/mock/art/studio/3110/cover.png",
+ "parentTitle" : "The Strange Case of Dr. Jekyll and Mr. Hyde",
+ "ratingKey" : "330001",
+ "studio" : "LibriVox",
+ "summary" : "Read by Bob Neufeld.",
+ "title" : "Part 1",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-strange-case-of-dr-jekyll-and-mr-hyde-by-robert-louis-stevenson/",
+ "https://archive.org/details/jekyll_hyde_1111_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "duration" : 2373000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 2,
+ "key" : "/library/metadata/330002",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-11-07",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3110",
+ "parentThumb" : "/mock/art/studio/3110/cover.png",
+ "parentTitle" : "The Strange Case of Dr. Jekyll and Mr. Hyde",
+ "ratingKey" : "330002",
+ "studio" : "LibriVox",
+ "summary" : "Read by Bob Neufeld.",
+ "title" : "Part 2",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-strange-case-of-dr-jekyll-and-mr-hyde-by-robert-louis-stevenson/",
+ "https://archive.org/details/jekyll_hyde_1111_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "duration" : 1943000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 3,
+ "key" : "/library/metadata/330003",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-11-07",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3110",
+ "parentThumb" : "/mock/art/studio/3110/cover.png",
+ "parentTitle" : "The Strange Case of Dr. Jekyll and Mr. Hyde",
+ "ratingKey" : "330003",
+ "studio" : "LibriVox",
+ "summary" : "Read by Bob Neufeld.",
+ "title" : "Part 3",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-strange-case-of-dr-jekyll-and-mr-hyde-by-robert-louis-stevenson/",
+ "https://archive.org/details/jekyll_hyde_1111_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "duration" : 1302000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 4,
+ "key" : "/library/metadata/330004",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-11-07",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3110",
+ "parentThumb" : "/mock/art/studio/3110/cover.png",
+ "parentTitle" : "The Strange Case of Dr. Jekyll and Mr. Hyde",
+ "ratingKey" : "330004",
+ "studio" : "LibriVox",
+ "summary" : "Read by Bob Neufeld.",
+ "title" : "Part 4",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-strange-case-of-dr-jekyll-and-mr-hyde-by-robert-louis-stevenson/",
+ "https://archive.org/details/jekyll_hyde_1111_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Genre" : [
+ {
+ "tag" : "Horror & Supernatural Fiction"
+ }
+ ],
+ "duration" : 3219000,
+ "grandparentRatingKey" : "3004",
+ "grandparentTitle" : "Robert Louis Stevenson",
+ "index" : 5,
+ "key" : "/library/metadata/330005",
+ "librarySectionID" : "library-audiobooks",
+ "librarySectionTitle" : "Audiobooks",
+ "originallyAvailableAt" : "2011-11-07",
+ "parentIndex" : 1,
+ "parentRatingKey" : "3110",
+ "parentThumb" : "/mock/art/studio/3110/cover.png",
+ "parentTitle" : "The Strange Case of Dr. Jekyll and Mr. Hyde",
+ "ratingKey" : "330005",
+ "studio" : "LibriVox",
+ "summary" : "Read by Bob Neufeld.",
+ "title" : "Part 5",
+ "type" : "track",
+ "year" : 2011
+ },
+ "relatedIDs" : [
+
+ ],
+ "sources" : [
+ "https://librivox.org/the-strange-case-of-dr-jekyll-and-mr-hyde-by-robert-louis-stevenson/",
+ "https://archive.org/details/jekyll_hyde_1111_librivox"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Country" : [
+ {
+ "tag" : "United States"
+ }
+ ],
+ "Director" : [
+ {
+ "id" : 50302,
+ "order" : 0,
+ "tag" : "Lewis Milestone",
+ "tagKey" : "50302"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "War"
+ },
+ {
+ "tag" : "Drama"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0020629"
+ }
+ ],
+ "Producer" : [
+ {
+ "id" : 50310,
+ "order" : 0,
+ "tag" : "Carl Laemmle Jr.",
+ "tagKey" : "50310"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "rottentomatoes://image.rating.ripe",
+ "type" : "critic",
+ "value" : 9.8
+ },
+ {
+ "image" : "rottentomatoes://image.rating.upright",
+ "type" : "audience",
+ "value" : 8.9
+ },
+ {
+ "image" : "imdb://image.rating",
+ "type" : "audience",
+ "value" : 8.1
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50303,
+ "order" : 0,
+ "role" : "Paul Bäumer",
+ "tag" : "Lew Ayres",
+ "tagKey" : "50303"
+ },
+ {
+ "id" : 50304,
+ "order" : 1,
+ "role" : "Katczinsky",
+ "tag" : "Louis Wolheim",
+ "tagKey" : "50304"
+ },
+ {
+ "id" : 50305,
+ "order" : 2,
+ "role" : "Himmelstoss",
+ "tag" : "John Wray",
+ "tagKey" : "50305"
+ },
+ {
+ "id" : 50306,
+ "order" : 3,
+ "role" : "Kantorek",
+ "tag" : "Arnold Lucy",
+ "tagKey" : "50306"
+ },
+ {
+ "id" : 50257,
+ "order" : 4,
+ "role" : "Kemmerich",
+ "tag" : "Ben Alexander",
+ "tagKey" : "50257"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50307,
+ "order" : 0,
+ "tag" : "George Abbott",
+ "tagKey" : "50307"
+ },
+ {
+ "id" : 50308,
+ "order" : 1,
+ "tag" : "Maxwell Anderson",
+ "tagKey" : "50308"
+ },
+ {
+ "id" : 50309,
+ "order" : 2,
+ "tag" : "Del Andrews",
+ "tagKey" : "50309"
+ }
+ ],
+ "art" : "/mock/art/movies/all-quiet-on-the-western-front/backdrop.jpg",
+ "audienceRating" : 8.9,
+ "audienceRatingImage" : "rottentomatoes://image.rating.upright",
+ "duration" : 9120000,
+ "key" : "/library/metadata/1119",
+ "librarySectionID" : "library-movies",
+ "librarySectionTitle" : "Movies",
+ "originallyAvailableAt" : "1930-08-24",
+ "rating" : 9.8,
+ "ratingImage" : "rottentomatoes://image.rating.ripe",
+ "ratingKey" : "1119",
+ "studio" : "Universal Pictures Corp.",
+ "summary" : "Paul Bäumer and his schoolmates enlist in the German army with dreams of glory. Life in the trenches of World War I replaces their enthusiasm with fear, grief, and a growing distance from the civilians who sent them to fight.",
+ "thumb" : "/mock/art/movies/all-quiet-on-the-western-front/poster.png",
+ "title" : "All Quiet on the Western Front",
+ "titleSort" : "All Quiet on the Western Front",
+ "type" : "movie",
+ "viewCount" : 0,
+ "year" : 1930
+ },
+ "relatedIDs" : [
+ "1115"
+ ],
+ "sources" : [
+ "https://catalog.afi.com/Catalog/moviedetails/2558",
+ "https://www.rottentomatoes.com/m/all_quiet_on_the_western_front",
+ "https://www.imdb.com/title/tt0020629/",
+ "https://web.law.duke.edu/cspd/publicdomainday/2026/"
+ ]
+ },
+ {
+ "addedAtSecondsAgo" : 300,
+ "extraIDs" : [
+
+ ],
+ "metadata" : {
+ "Country" : [
+ {
+ "tag" : "United States"
+ }
+ ],
+ "Director" : [
+ {
+ "id" : 50311,
+ "order" : 0,
+ "tag" : "Victor Heerman",
+ "tagKey" : "50311"
+ }
+ ],
+ "Genre" : [
+ {
+ "tag" : "Comedy"
+ }
+ ],
+ "Guid" : [
+ {
+ "id" : "imdb://tt0020640"
+ }
+ ],
+ "Rating" : [
+ {
+ "image" : "rottentomatoes://image.rating.ripe",
+ "type" : "critic",
+ "value" : 9.7
+ },
+ {
+ "image" : "rottentomatoes://image.rating.upright",
+ "type" : "audience",
+ "value" : 8.8
+ }
+ ],
+ "Role" : [
+ {
+ "id" : 50312,
+ "order" : 0,
+ "role" : "Capt. Jeffrey Spaulding",
+ "tag" : "Groucho Marx",
+ "tagKey" : "50312"
+ },
+ {
+ "id" : 50313,
+ "order" : 1,
+ "role" : "Professor",
+ "tag" : "Harpo Marx",
+ "tagKey" : "50313"
+ },
+ {
+ "id" : 50314,
+ "order" : 2,
+ "role" : "Signor Emanuel Ravelli",
+ "tag" : "Chico Marx",
+ "tagKey" : "50314"
+ },
+ {
+ "id" : 50315,
+ "order" : 3,
+ "role" : "Horatio Jamison",
+ "tag" : "Zeppo Marx",
+ "tagKey" : "50315"
+ },
+ {
+ "id" : 50316,
+ "order" : 4,
+ "role" : "Arabella Rittenhouse",
+ "tag" : "Lillian Roth",
+ "tagKey" : "50316"
+ },
+ {
+ "id" : 50317,
+ "order" : 5,
+ "role" : "Mrs. Rittenhouse",
+ "tag" : "Margaret Dumont",
+ "tagKey" : "50317"
+ }
+ ],
+ "Writer" : [
+ {
+ "id" : 50168,
+ "order" : 0,
+ "tag" : "Morrie Ryskind",
+ "tagKey" : "50168"
+ },
+ {
+ "id" : 50318,
+ "order" : 1,
+ "tag" : "Pierre Collings",
+ "tagKey" : "50318"
+ }
+ ],
+ "art" : "/mock/art/movies/animal-crackers/backdrop.jpg",
+ "audienceRating" : 8.8,
+ "audienceRatingImage" : "rottentomatoes://image.rating.upright",
+ "duration" : 5820000,
+ "key" : "/library/metadata/1120",
+ "librarySectionID" : "library-movies",
+ "librarySectionTitle" : "Movies",
+ "originallyAvailableAt" : "1930-09-06",
+ "rating" : 9.7,
+ "ratingImage" : "rottentomatoes://image.rating.ripe",
+ "ratingKey" : "1120",
+ "studio" : "Paramount Publix Corp.",
+ "summary" : "A society hostess welcomes celebrated explorer Captain Spaulding to her country estate. When a valuable painting becomes the target of rival substitution schemes, the Marx Brothers turn the house party into a tangle of mistaken identities, musical interruptions, and absurd detective work.",
+ "thumb" : "/mock/art/movies/animal-crackers/poster.png",
+ "title" : "Animal Crackers",
+ "titleSort" : "Animal Crackers",
+ "type" : "movie",
+ "viewCount" : 0,
+ "year" : 1930
+ },
+ "relatedIDs" : [
+ "1111",
+ "1103"
+ ],
+ "sources" : [
+ "https://catalog.afi.com/Film/2591-ANIMAL-CRACKERS",
+ "https://www.rottentomatoes.com/m/animal_crackers",
+ "https://www.imdb.com/title/tt0020640/",
+ "https://web.law.duke.edu/cspd/publicdomainday/2026/"
+ ]
+ }
+]
\ No newline at end of file
diff --git a/PlexBar/Resources/MockServer/mock-server.json b/PlexBar/Resources/MockServer/mock-server.json
new file mode 100644
index 0000000..687f13a
--- /dev/null
+++ b/PlexBar/Resources/MockServer/mock-server.json
@@ -0,0 +1,1149 @@
+{
+ "activeSessions" : [
+ {
+ "deviceID" : 1,
+ "mediaDecision" : "directplay",
+ "mediaID" : "1101",
+ "mediaStreams" : [
+ {
+ "codec" : "h264",
+ "displayTitle" : "1080p (H.264)",
+ "streamType" : 1
+ },
+ {
+ "codec" : "ac3",
+ "displayTitle" : "English (AC3 5.1)",
+ "selected" : true,
+ "streamType" : 2
+ }
+ ],
+ "mediaType" : "movie",
+ "session" : {
+ "bandwidth" : 12000,
+ "id" : "playback-1"
+ },
+ "sessionKey" : "stream-1",
+ "state" : "playing",
+ "userID" : 11,
+ "viewOffset" : 3146000
+ },
+ {
+ "deviceID" : 2,
+ "mediaDecision" : "transcode",
+ "mediaID" : "1102",
+ "mediaStreams" : [
+ {
+ "bitrate" : 8000,
+ "codec" : "h264",
+ "decision" : "transcode",
+ "displayTitle" : "1080p (HEVC Main 10)",
+ "streamType" : 1
+ },
+ {
+ "bitrate" : 256,
+ "codec" : "aac",
+ "decision" : "transcode",
+ "displayTitle" : "English (AC3 5.1)",
+ "selected" : true,
+ "streamType" : 2
+ },
+ {
+ "codec" : "ass",
+ "decision" : "transcode",
+ "displayTitle" : "English (SRT)",
+ "language" : "English",
+ "selected" : true,
+ "streamType" : 3
+ }
+ ],
+ "mediaType" : "movie",
+ "session" : {
+ "bandwidth" : 8400,
+ "id" : "playback-2"
+ },
+ "sessionKey" : "stream-2",
+ "state" : "paused",
+ "transcodeSession" : {
+ "audioCodec" : "aac",
+ "audioDecision" : "transcode",
+ "key" : "transcode-2",
+ "sourceAudioCodec" : "ac3",
+ "sourceVideoCodec" : "hevc",
+ "transcodeHwDecoding" : "videotoolbox",
+ "transcodeHwEncoding" : "videotoolbox",
+ "videoCodec" : "h264",
+ "videoDecision" : "transcode"
+ },
+ "userID" : 12,
+ "viewOffset" : 3750000
+ },
+ {
+ "deviceID" : 3,
+ "mediaDecision" : "transcode",
+ "mediaID" : "1103",
+ "mediaStreams" : [
+ {
+ "codec" : "h264",
+ "decision" : "copy",
+ "displayTitle" : "1080p (H.264)",
+ "streamType" : 1
+ },
+ {
+ "codec" : "aac",
+ "decision" : "copy",
+ "displayTitle" : "English (AAC Stereo)",
+ "selected" : true,
+ "streamType" : 2
+ }
+ ],
+ "mediaType" : "movie",
+ "session" : {
+ "bandwidth" : 10200,
+ "id" : "playback-3"
+ },
+ "sessionKey" : "stream-3",
+ "state" : "playing",
+ "userID" : 13,
+ "viewOffset" : 1458000
+ },
+ {
+ "audioStream" : {
+ "codec" : "aac",
+ "id" : 3103001,
+ "levels" : [
+ -26.9,
+ -28.2,
+ -28.2,
+ -27.9,
+ -29.4,
+ -29.1,
+ -28.5,
+ -28.9,
+ -27.8,
+ -28,
+ -27.5,
+ -27.7,
+ -27.2,
+ -27.9,
+ -29,
+ -27.4,
+ -29.2,
+ -29.8,
+ -29.5,
+ -29.2,
+ -29.8,
+ -29.5,
+ -28.7,
+ -27.9,
+ -28.2,
+ -28.1,
+ -27.5,
+ -28.1,
+ -28.1,
+ -27.8,
+ -27.3,
+ -27.5,
+ -27.7,
+ -27.3,
+ -28.3,
+ -28,
+ -28.3,
+ -28.4,
+ -27.4,
+ -26.5,
+ -26.6,
+ -26.5,
+ -27.1,
+ -28.1,
+ -27.4,
+ -25.7,
+ -25.5,
+ -25.8,
+ -26.7,
+ -26.8,
+ -25.7,
+ -27.1,
+ -27.8,
+ -26.9,
+ -28.8,
+ -28.5,
+ -28.4,
+ -27.8,
+ -27.5,
+ -28,
+ -27.8,
+ -28.3,
+ -26.2,
+ -26.7,
+ -27.6,
+ -26.9,
+ -28,
+ -28.1,
+ -27.7,
+ -27.5,
+ -30.3,
+ -29.4,
+ -27.2,
+ -21.7,
+ -22.9,
+ -22.1,
+ -21.2,
+ -21.9,
+ -22,
+ -23,
+ -26.6,
+ -27.1,
+ -27.3,
+ -27.9,
+ -27.8,
+ -28.1,
+ -26.2,
+ -27.7,
+ -27.6,
+ -27.7,
+ -27.3,
+ -27.2,
+ -27.7,
+ -27.3,
+ -27.5,
+ -39.9
+ ],
+ "selected" : true,
+ "streamType" : 2
+ },
+ "deviceID" : 5,
+ "mediaDecision" : "directplay",
+ "mediaID" : "32301",
+ "mediaType" : "track",
+ "session" : {
+ "bandwidth" : 320,
+ "id" : "playback-4"
+ },
+ "sessionKey" : "stream-4",
+ "state" : "playing",
+ "userID" : 15,
+ "viewOffset" : 120000
+ }
+ ],
+ "artwork" : [
+ {
+ "path" : "/mock/avatars/dana-scully.png",
+ "resource" : "avatars/dana-scully.png"
+ },
+ {
+ "path" : "/mock/avatars/darlene-alderson.png",
+ "resource" : "avatars/darlene-alderson.png"
+ },
+ {
+ "path" : "/mock/avatars/elliot-alderson.png",
+ "resource" : "avatars/elliot-alderson.png"
+ },
+ {
+ "path" : "/mock/avatars/le-petit-prince.png",
+ "resource" : "avatars/le-petit-prince.png"
+ },
+ {
+ "path" : "/mock/avatars/popeye.png",
+ "resource" : "avatars/popeye.png"
+ },
+ {
+ "path" : "/mock/avatars/scrump-toggins.png",
+ "resource" : "avatars/scrump-toggins.png"
+ },
+ {
+ "path" : "/mock/avatars/tommy-shelby.png",
+ "resource" : "avatars/tommy-shelby.png"
+ },
+ {
+ "path" : "/mock/art/movies/charade/poster.png",
+ "resource" : "art/movies/charade/poster.png"
+ },
+ {
+ "path" : "/mock/art/movies/night-of-the-living-dead/poster.png",
+ "resource" : "art/movies/night-of-the-living-dead/poster.png"
+ },
+ {
+ "path" : "/mock/art/movies/sherlock-jr/poster.png",
+ "resource" : "art/movies/sherlock-jr/poster.png"
+ },
+ {
+ "path" : "/mock/art/tv/abbott-and-costello/poster.png",
+ "resource" : "art/tv/abbott-and-costello/poster.png"
+ },
+ {
+ "path" : "/mock/art/tv/adventures-of-ozzie-and-harriet/poster.png",
+ "resource" : "art/tv/adventures-of-ozzie-and-harriet/poster.png"
+ },
+ {
+ "path" : "/mock/art/tv/one-step-beyond/poster.png",
+ "resource" : "art/tv/one-step-beyond/poster.png"
+ },
+ {
+ "path" : "/mock/art/audiobooks/dracula/cover.png",
+ "resource" : "art/audiobooks/dracula/cover.png"
+ },
+ {
+ "path" : "/mock/art/audiobooks/the-time-machine/cover.png",
+ "resource" : "art/audiobooks/the-time-machine/cover.png"
+ },
+ {
+ "path" : "/mock/art/audiobooks/war-of-the-worlds/cover.png",
+ "resource" : "art/audiobooks/war-of-the-worlds/cover.png"
+ },
+ {
+ "path" : "/mock/art/movies/charade/backdrop.jpg",
+ "resource" : "art/movies/charade/backdrop.jpg",
+ "source" : "https://images.plex.tv/photo?size=large-1920&scale=1&url=https%3A%2F%2Fmetadata-static.plex.tv%2F7%2Fgracenote%2F7959fdb01594576f8fa42332f6ab1500.jpg"
+ },
+ {
+ "path" : "/mock/art/movies/night-of-the-living-dead/backdrop.jpg",
+ "resource" : "art/movies/night-of-the-living-dead/backdrop.jpg",
+ "source" : "https://images.plex.tv/photo?size=large-1920&scale=1&url=https%3A%2F%2Fmetadata-static.plex.tv%2F2%2Fgracenote%2F2b8d354188de1d779b63dd49ee5af6e3.jpg"
+ },
+ {
+ "path" : "/mock/art/movies/sherlock-jr/backdrop.jpg",
+ "resource" : "art/movies/sherlock-jr/backdrop.jpg",
+ "source" : "https://images.plex.tv/photo?size=large-1920&scale=1&url=https%3A%2F%2Fmetadata-static.plex.tv%2Ff%2Fgracenote%2Ff2628c473cad8772cc173b9e37a763d2.jpg"
+ },
+ {
+ "path" : "/mock/art/tv/one-step-beyond/backdrop.jpg",
+ "resource" : "art/tv/one-step-beyond/backdrop.jpg",
+ "source" : "https://images.plex.tv/photo?size=large-1920&scale=1&url=https%3A%2F%2Fimage.tmdb.org%2Ft%2Fp%2Foriginal%2FrYWPWFUF3N8zIEzyH2git8hbXWy.jpg"
+ },
+ {
+ "path" : "/mock/art/tv/adventures-of-ozzie-and-harriet/backdrop.jpg",
+ "resource" : "art/tv/adventures-of-ozzie-and-harriet/backdrop.jpg",
+ "source" : "https://images.plex.tv/photo?size=large-1920&scale=1&url=https%3A%2F%2Fimage.tmdb.org%2Ft%2Fp%2Foriginal%2Fz9ipjaUaoRWMl6vvQnLhpuFfkBu.jpg"
+ },
+ {
+ "path" : "/mock/art/tv/abbott-and-costello/backdrop.jpg",
+ "resource" : "art/tv/abbott-and-costello/backdrop.jpg",
+ "source" : "https://images.plex.tv/photo?size=large-1920&scale=1&url=https%3A%2F%2Fimage.tmdb.org%2Ft%2Fp%2Foriginal%2F7gWTV42DZa6qCqm0OLqEEiWDs7h.jpg"
+ },
+ {
+ "path" : "/mock/art/movies/metropolis/poster.png",
+ "resource" : "art/movies/metropolis/poster.png"
+ },
+ {
+ "path" : "/mock/art/movies/metropolis/backdrop.jpg",
+ "resource" : "art/movies/metropolis/backdrop.jpg"
+ },
+ {
+ "path" : "/mock/art/movies/a-star-is-born/poster.png",
+ "resource" : "art/movies/a-star-is-born/poster.png"
+ },
+ {
+ "path" : "/mock/art/movies/nosferatu/backdrop.jpg",
+ "resource" : "art/movies/nosferatu/backdrop.jpg"
+ },
+ {
+ "path" : "/mock/art/movies/nosferatu/poster.png",
+ "resource" : "art/movies/nosferatu/poster.png"
+ },
+ {
+ "path" : "/mock/art/movies/a-star-is-born/backdrop.jpg",
+ "resource" : "art/movies/a-star-is-born/backdrop.jpg"
+ },
+ {
+ "path" : "/mock/art/movies/the-lost-world/backdrop.jpg",
+ "resource" : "art/movies/the-lost-world/backdrop.jpg"
+ },
+ {
+ "path" : "/mock/art/movies/the-phantom-of-the-opera/poster.png",
+ "resource" : "art/movies/the-phantom-of-the-opera/poster.png"
+ },
+ {
+ "path" : "/mock/art/movies/the-phantom-of-the-opera/backdrop.jpg",
+ "resource" : "art/movies/the-phantom-of-the-opera/backdrop.jpg"
+ },
+ {
+ "path" : "/mock/art/movies/the-lost-world/poster.png",
+ "resource" : "art/movies/the-lost-world/poster.png"
+ },
+ {
+ "path" : "/mock/art/movies/fear-and-desire/poster.png",
+ "resource" : "art/movies/fear-and-desire/poster.png"
+ },
+ {
+ "path" : "/mock/art/movies/fear-and-desire/backdrop.jpg",
+ "resource" : "art/movies/fear-and-desire/backdrop.jpg"
+ },
+ {
+ "path" : "/mock/art/movies/my-man-godfrey/backdrop.jpg",
+ "resource" : "art/movies/my-man-godfrey/backdrop.jpg"
+ },
+ {
+ "path" : "/mock/art/movies/its-a-wonderful-life/backdrop.jpg",
+ "resource" : "art/movies/its-a-wonderful-life/backdrop.jpg"
+ },
+ {
+ "path" : "/mock/art/movies/its-a-wonderful-life/poster.png",
+ "resource" : "art/movies/its-a-wonderful-life/poster.png"
+ },
+ {
+ "path" : "/mock/art/movies/my-man-godfrey/poster.png",
+ "resource" : "art/movies/my-man-godfrey/poster.png"
+ },
+ {
+ "path" : "/mock/art/movies/plan-9-from-outer-space/backdrop.jpg",
+ "resource" : "art/movies/plan-9-from-outer-space/backdrop.jpg"
+ },
+ {
+ "path" : "/mock/art/movies/plan-9-from-outer-space/poster.png",
+ "resource" : "art/movies/plan-9-from-outer-space/poster.png"
+ },
+ {
+ "path" : "/mock/art/movies/reefer-madness/backdrop.jpg",
+ "resource" : "art/movies/reefer-madness/backdrop.jpg"
+ },
+ {
+ "path" : "/mock/art/movies/reefer-madness/poster.png",
+ "resource" : "art/movies/reefer-madness/poster.png"
+ },
+ {
+ "path" : "/mock/art/movies/the-little-shop-of-horrors/backdrop.jpg",
+ "resource" : "art/movies/the-little-shop-of-horrors/backdrop.jpg"
+ },
+ {
+ "path" : "/mock/art/movies/the-general/backdrop.jpg",
+ "resource" : "art/movies/the-general/backdrop.jpg"
+ },
+ {
+ "path" : "/mock/art/movies/the-bat/backdrop.jpg",
+ "resource" : "art/movies/the-bat/backdrop.jpg"
+ },
+ {
+ "path" : "/mock/art/movies/the-last-man-on-earth/backdrop.jpg",
+ "resource" : "art/movies/the-last-man-on-earth/backdrop.jpg"
+ },
+ {
+ "path" : "/mock/art/movies/the-general/poster.png",
+ "resource" : "art/movies/the-general/poster.png"
+ },
+ {
+ "path" : "/mock/art/movies/the-little-shop-of-horrors/poster.png",
+ "resource" : "art/movies/the-little-shop-of-horrors/poster.png"
+ },
+ {
+ "path" : "/mock/art/movies/the-bat/poster.png",
+ "resource" : "art/movies/the-bat/poster.png"
+ },
+ {
+ "path" : "/mock/art/movies/the-last-man-on-earth/poster.png",
+ "resource" : "art/movies/the-last-man-on-earth/poster.png"
+ },
+ {
+ "path" : "/mock/art/studio/2111/backdrop.jpg",
+ "resource" : "art/studio/2111/backdrop.jpg"
+ },
+ {
+ "path" : "/mock/art/movies/the-stranger/poster.png",
+ "resource" : "art/movies/the-stranger/poster.png"
+ },
+ {
+ "path" : "/mock/art/studio/2111/poster.png",
+ "resource" : "art/studio/2111/poster.png"
+ },
+ {
+ "path" : "/mock/art/movies/the-stranger/backdrop.jpg",
+ "resource" : "art/movies/the-stranger/backdrop.jpg"
+ },
+ {
+ "path" : "/mock/art/studio/2110/backdrop.jpg",
+ "resource" : "art/studio/2110/backdrop.jpg"
+ },
+ {
+ "path" : "/mock/art/studio/2110/poster.png",
+ "resource" : "art/studio/2110/poster.png"
+ },
+ {
+ "path" : "/mock/art/studio/2107/backdrop.jpg",
+ "resource" : "art/studio/2107/backdrop.jpg"
+ },
+ {
+ "path" : "/mock/art/studio/2107/poster.png",
+ "resource" : "art/studio/2107/poster.png"
+ },
+ {
+ "path" : "/mock/art/studio/2108/poster.png",
+ "resource" : "art/studio/2108/poster.png"
+ },
+ {
+ "path" : "/mock/art/studio/2104/poster.png",
+ "resource" : "art/studio/2104/poster.png"
+ },
+ {
+ "path" : "/mock/art/studio/2104/backdrop.jpg",
+ "resource" : "art/studio/2104/backdrop.jpg"
+ },
+ {
+ "path" : "/mock/art/studio/2105/poster.png",
+ "resource" : "art/studio/2105/poster.png"
+ },
+ {
+ "path" : "/mock/art/studio/2105/backdrop.jpg",
+ "resource" : "art/studio/2105/backdrop.jpg"
+ },
+ {
+ "path" : "/mock/art/studio/2106/backdrop.jpg",
+ "resource" : "art/studio/2106/backdrop.jpg"
+ },
+ {
+ "path" : "/mock/art/studio/2108/backdrop.jpg",
+ "resource" : "art/studio/2108/backdrop.jpg"
+ },
+ {
+ "path" : "/mock/art/studio/2109/poster.png",
+ "resource" : "art/studio/2109/poster.png"
+ },
+ {
+ "path" : "/mock/art/studio/2109/backdrop.jpg",
+ "resource" : "art/studio/2109/backdrop.jpg"
+ },
+ {
+ "path" : "/mock/art/studio/2106/poster.png",
+ "resource" : "art/studio/2106/poster.png"
+ },
+ {
+ "path" : "/mock/art/studio/2112/poster.png",
+ "resource" : "art/studio/2112/poster.png"
+ },
+ {
+ "path" : "/mock/art/studio/2112/backdrop.jpg",
+ "resource" : "art/studio/2112/backdrop.jpg"
+ },
+ {
+ "path" : "/mock/art/studio/3104/cover.png",
+ "resource" : "art/studio/3104/cover.png"
+ },
+ {
+ "path" : "/mock/art/studio/3106/cover.png",
+ "resource" : "art/studio/3106/cover.png"
+ },
+ {
+ "path" : "/mock/art/studio/3107/cover.png",
+ "resource" : "art/studio/3107/cover.png"
+ },
+ {
+ "path" : "/mock/art/studio/3105/cover.png",
+ "resource" : "art/studio/3105/cover.png"
+ },
+ {
+ "path" : "/mock/avatars/thebaumer.png",
+ "resource" : "avatars/thebaumer.png"
+ },
+ {
+ "path" : "/mock/avatars/le0n.png",
+ "resource" : "avatars/le0n.png"
+ },
+ {
+ "path" : "/mock/avatars/joi.png",
+ "resource" : "avatars/joi.png"
+ },
+ {
+ "path" : "/mock/art/studio/3109/cover.png",
+ "resource" : "art/studio/3109/cover.png"
+ },
+ {
+ "path" : "/mock/art/studio/3110/cover.png",
+ "resource" : "art/studio/3110/cover.png"
+ },
+ {
+ "path" : "/mock/art/studio/3108/cover.png",
+ "resource" : "art/studio/3108/cover.png"
+ },
+ {
+ "path" : "/mock/art/movies/all-quiet-on-the-western-front/poster.png",
+ "resource" : "art/movies/all-quiet-on-the-western-front/poster.png"
+ },
+ {
+ "path" : "/mock/art/movies/animal-crackers/backdrop.jpg",
+ "resource" : "art/movies/animal-crackers/backdrop.jpg"
+ },
+ {
+ "path" : "/mock/art/movies/all-quiet-on-the-western-front/backdrop.jpg",
+ "resource" : "art/movies/all-quiet-on-the-western-front/backdrop.jpg"
+ },
+ {
+ "path" : "/mock/art/movies/animal-crackers/poster.png",
+ "resource" : "art/movies/animal-crackers/poster.png"
+ }
+ ],
+ "authenticatedUserID" : 16,
+ "historyEvents" : [
+ {
+ "deviceID" : 1,
+ "historyKey" : "/status/sessions/history/801",
+ "mediaID" : "1101",
+ "mediaType" : "movie",
+ "userID" : 11,
+ "viewedAtSecondsAgo" : 3600
+ },
+ {
+ "deviceID" : 2,
+ "historyKey" : "/status/sessions/history/802",
+ "mediaID" : "1102",
+ "mediaType" : "movie",
+ "userID" : 12,
+ "viewedAtSecondsAgo" : 7200
+ },
+ {
+ "deviceID" : 3,
+ "historyKey" : "/status/sessions/history/803",
+ "mediaID" : "1103",
+ "mediaType" : "movie",
+ "userID" : 13,
+ "viewedAtSecondsAgo" : 43200
+ },
+ {
+ "deviceID" : 4,
+ "historyKey" : "/status/sessions/history/804",
+ "mediaID" : "1101",
+ "mediaType" : "movie",
+ "userID" : 14,
+ "viewedAtSecondsAgo" : 86400
+ },
+ {
+ "deviceID" : 5,
+ "historyKey" : "/status/sessions/history/805",
+ "mediaID" : "1102",
+ "mediaType" : "movie",
+ "userID" : 15,
+ "viewedAtSecondsAgo" : 172800
+ },
+ {
+ "deviceID" : 1,
+ "historyKey" : "/status/sessions/history/806",
+ "mediaID" : "1103",
+ "mediaType" : "movie",
+ "userID" : 11,
+ "viewedAtSecondsAgo" : 345600
+ },
+ {
+ "deviceID" : 5,
+ "historyKey" : "/status/sessions/history/807",
+ "mediaID" : "2201",
+ "mediaType" : "episode",
+ "userID" : 15,
+ "viewedAtSecondsAgo" : 10800
+ },
+ {
+ "deviceID" : 4,
+ "historyKey" : "/status/sessions/history/808",
+ "mediaID" : "2202",
+ "mediaType" : "episode",
+ "userID" : 14,
+ "viewedAtSecondsAgo" : 216000
+ },
+ {
+ "deviceID" : 2,
+ "historyKey" : "/status/sessions/history/809",
+ "mediaID" : "2203",
+ "mediaType" : "episode",
+ "userID" : 12,
+ "viewedAtSecondsAgo" : 259200
+ },
+ {
+ "deviceID" : 6,
+ "historyKey" : "/status/sessions/history/810",
+ "mediaID" : "2201",
+ "mediaType" : "episode",
+ "userID" : 16,
+ "viewedAtSecondsAgo" : 14400
+ },
+ {
+ "deviceID" : 6,
+ "historyKey" : "/status/sessions/history/811",
+ "mediaID" : "1102",
+ "mediaType" : "movie",
+ "userID" : 16,
+ "viewedAtSecondsAgo" : 129600
+ },
+ {
+ "deviceID" : 7,
+ "historyKey" : "/status/sessions/history/812",
+ "mediaID" : "2203",
+ "mediaType" : "episode",
+ "userID" : 17,
+ "viewedAtSecondsAgo" : 1800
+ },
+ {
+ "deviceID" : 7,
+ "historyKey" : "/status/sessions/history/813",
+ "mediaID" : "1101",
+ "mediaType" : "movie",
+ "userID" : 17,
+ "viewedAtSecondsAgo" : 28800
+ },
+ {
+ "deviceID" : 7,
+ "historyKey" : "/status/sessions/history/814",
+ "mediaID" : "2202",
+ "mediaType" : "episode",
+ "userID" : 17,
+ "viewedAtSecondsAgo" : 604800
+ },
+ {
+ "deviceID" : 1,
+ "historyKey" : "/status/sessions/history/815",
+ "mediaID" : "2202",
+ "mediaType" : "episode",
+ "userID" : 11,
+ "viewedAtSecondsAgo" : 93600
+ },
+ {
+ "deviceID" : 1,
+ "historyKey" : "/status/sessions/history/816",
+ "mediaID" : "1102",
+ "mediaType" : "movie",
+ "userID" : 11,
+ "viewedAtSecondsAgo" : 432000
+ },
+ {
+ "deviceID" : 2,
+ "historyKey" : "/status/sessions/history/817",
+ "mediaID" : "1101",
+ "mediaType" : "movie",
+ "userID" : 12,
+ "viewedAtSecondsAgo" : 691200
+ },
+ {
+ "deviceID" : 5,
+ "historyKey" : "/status/sessions/history/818",
+ "mediaID" : "1103",
+ "mediaType" : "movie",
+ "userID" : 15,
+ "viewedAtSecondsAgo" : 777600
+ },
+ {
+ "deviceID" : 8,
+ "historyKey" : "/status/sessions/history/819",
+ "mediaID" : "1110",
+ "mediaType" : "movie",
+ "userID" : 18,
+ "viewedAtSecondsAgo" : 86400
+ },
+ {
+ "deviceID" : 8,
+ "historyKey" : "/status/sessions/history/820",
+ "mediaID" : "1110",
+ "mediaType" : "movie",
+ "userID" : 18,
+ "viewedAtSecondsAgo" : 345600
+ },
+ {
+ "deviceID" : 8,
+ "historyKey" : "/status/sessions/history/821",
+ "mediaID" : "1101",
+ "mediaType" : "movie",
+ "userID" : 18,
+ "viewedAtSecondsAgo" : 604800
+ },
+ {
+ "deviceID" : 9,
+ "historyKey" : "/status/sessions/history/822",
+ "mediaID" : "1103",
+ "mediaType" : "movie",
+ "userID" : 19,
+ "viewedAtSecondsAgo" : 172800
+ },
+ {
+ "deviceID" : 9,
+ "historyKey" : "/status/sessions/history/823",
+ "mediaID" : "1103",
+ "mediaType" : "movie",
+ "userID" : 19,
+ "viewedAtSecondsAgo" : 691200
+ },
+ {
+ "deviceID" : 9,
+ "historyKey" : "/status/sessions/history/824",
+ "mediaID" : "1107",
+ "mediaType" : "movie",
+ "userID" : 19,
+ "viewedAtSecondsAgo" : 432000
+ },
+ {
+ "deviceID" : 10,
+ "historyKey" : "/status/sessions/history/825",
+ "mediaID" : "1105",
+ "mediaType" : "movie",
+ "userID" : 20,
+ "viewedAtSecondsAgo" : 172800
+ },
+ {
+ "deviceID" : 10,
+ "historyKey" : "/status/sessions/history/826",
+ "mediaID" : "32406",
+ "mediaType" : "track",
+ "userID" : 20,
+ "viewedAtSecondsAgo" : 432000
+ },
+ {
+ "deviceID" : 10,
+ "historyKey" : "/status/sessions/history/827",
+ "mediaID" : "32601",
+ "mediaType" : "track",
+ "userID" : 20,
+ "viewedAtSecondsAgo" : 86400
+ }
+ ],
+ "libraries" : [
+ {
+ "contentChangedAtSecondsAgo" : 7200,
+ "entries" : [
+ {
+ "mediaID" : "1101"
+ },
+ {
+ "mediaID" : "1102"
+ },
+ {
+ "mediaID" : "1103"
+ },
+ {
+ "mediaID" : "1104"
+ },
+ {
+ "mediaID" : "1105"
+ },
+ {
+ "mediaID" : "1106"
+ },
+ {
+ "mediaID" : "1107"
+ },
+ {
+ "mediaID" : "1108"
+ },
+ {
+ "mediaID" : "1109"
+ },
+ {
+ "mediaID" : "1110"
+ },
+ {
+ "mediaID" : "1111"
+ },
+ {
+ "mediaID" : "1112"
+ },
+ {
+ "mediaID" : "1113"
+ },
+ {
+ "mediaID" : "1114"
+ },
+ {
+ "mediaID" : "1115"
+ },
+ {
+ "mediaID" : "1116"
+ },
+ {
+ "mediaID" : "1117"
+ },
+ {
+ "mediaID" : "1118"
+ },
+ {
+ "mediaID" : "1119"
+ },
+ {
+ "mediaID" : "1120"
+ }
+ ],
+ "id" : "library-movies",
+ "scannedAtSecondsAgo" : 9600,
+ "title" : "Movies",
+ "type" : "movie",
+ "updatedAtSecondsAgo" : 10800
+ },
+ {
+ "contentChangedAtSecondsAgo" : 18000,
+ "entries" : [
+ {
+ "mediaID" : "2103"
+ },
+ {
+ "mediaID" : "2102"
+ },
+ {
+ "mediaID" : "2101"
+ },
+ {
+ "mediaID" : "2104"
+ },
+ {
+ "mediaID" : "2105"
+ },
+ {
+ "mediaID" : "2106"
+ },
+ {
+ "mediaID" : "2107"
+ },
+ {
+ "mediaID" : "2108"
+ },
+ {
+ "mediaID" : "2109"
+ },
+ {
+ "mediaID" : "2110"
+ },
+ {
+ "mediaID" : "2111"
+ },
+ {
+ "mediaID" : "2112"
+ }
+ ],
+ "id" : "library-tv-shows",
+ "scannedAtSecondsAgo" : 20400,
+ "title" : "TV Shows",
+ "type" : "show",
+ "updatedAtSecondsAgo" : 21600
+ },
+ {
+ "contentChangedAtSecondsAgo" : 28800,
+ "entries" : [
+ {
+ "mediaID" : "3001"
+ },
+ {
+ "mediaID" : "3002"
+ },
+ {
+ "mediaID" : "3003"
+ },
+ {
+ "mediaID" : "3004"
+ },
+ {
+ "mediaID" : "3005"
+ },
+ {
+ "mediaID" : "3006"
+ },
+ {
+ "mediaID" : "3007"
+ },
+ {
+ "mediaID" : "3008"
+ }
+ ],
+ "id" : "library-audiobooks",
+ "scannedAtSecondsAgo" : 34200,
+ "title" : "Audiobooks",
+ "type" : "artist",
+ "updatedAtSecondsAgo" : 36000
+ }
+ ],
+ "server" : {
+ "accessToken" : "plexbar-debug-mock-server-token",
+ "connections" : [
+ {
+ "local" : true,
+ "relay" : false,
+ "uri" : "https://demo.plexbar.local:32400"
+ }
+ ],
+ "id" : "debug-mock-server",
+ "name" : "Mock Server",
+ "productVersion" : "1.43.1.10611-1e34174b1"
+ },
+ "users" : [
+ {
+ "avatar" : "/mock/avatars/dana-scully.png",
+ "devices" : [
+ {
+ "connection" : {
+ "address" : "192.168.1.11",
+ "local" : true,
+ "relayed" : false,
+ "remotePublicAddress" : "198.51.100.11",
+ "resolvedLocation" : "Portland, OR",
+ "secure" : true
+ },
+ "id" : 1,
+ "machineIdentifier" : "den-apple-tv",
+ "platform" : "tvOS",
+ "product" : "Plex for Apple TV",
+ "title" : "Apple TV"
+ }
+ ],
+ "id" : 11,
+ "username" : "scully"
+ },
+ {
+ "avatar" : "/mock/avatars/elliot-alderson.png",
+ "devices" : [
+ {
+ "connection" : {
+ "address" : "172.16.0.41",
+ "local" : false,
+ "relayed" : false,
+ "remotePublicAddress" : "203.0.113.24",
+ "resolvedLocation" : "Brooklyn, NY",
+ "secure" : true
+ },
+ "id" : 2,
+ "machineIdentifier" : "elliot-iceweasel",
+ "platform" : "Linux",
+ "product" : "Plex Web",
+ "title" : "Iceweasel"
+ }
+ ],
+ "email" : "samsepi0l@proton.me",
+ "id" : 12,
+ "username" : "Elliot"
+ },
+ {
+ "avatar" : "/mock/avatars/le-petit-prince.png",
+ "devices" : [
+ {
+ "connection" : {
+ "address" : "10.0.0.18",
+ "local" : true,
+ "relayed" : false,
+ "remotePublicAddress" : "198.51.100.73",
+ "resolvedLocation" : "Lyon, France",
+ "secure" : true
+ },
+ "id" : 3,
+ "machineIdentifier" : "office-safari",
+ "platform" : "macOS",
+ "product" : "Plex Web",
+ "title" : "Safari"
+ }
+ ],
+ "id" : 13,
+ "username" : "petit_prince"
+ },
+ {
+ "avatar" : "/mock/avatars/popeye.png",
+ "devices" : [
+ {
+ "connection" : {
+
+ },
+ "id" : 4,
+ "machineIdentifier" : "mock-device-4",
+ "title" : "popeye23’s device"
+ }
+ ],
+ "id" : 14,
+ "username" : "popeye23"
+ },
+ {
+ "avatar" : "/mock/avatars/tommy-shelby.png",
+ "devices" : [
+ {
+ "connection" : {
+ "address" : "10.20.0.44",
+ "local" : false,
+ "relayed" : false,
+ "remotePublicAddress" : "203.0.113.91",
+ "resolvedLocation" : "Birmingham, UK",
+ "secure" : true
+ },
+ "id" : 5,
+ "machineIdentifier" : "tommy-prologue",
+ "platform" : "iOS",
+ "product" : "Prologue",
+ "title" : "iPhone"
+ }
+ ],
+ "id" : 15,
+ "username" : "TommyS"
+ },
+ {
+ "avatar" : "/mock/avatars/darlene-alderson.png",
+ "devices" : [
+ {
+ "connection" : {
+
+ },
+ "id" : 6,
+ "machineIdentifier" : "mock-device-6",
+ "title" : "D0loresH4ze’s device"
+ }
+ ],
+ "email" : "d0loresh4ze@proton.me",
+ "id" : 16,
+ "username" : "D0loresH4ze"
+ },
+ {
+ "avatar" : "/mock/avatars/scrump-toggins.png",
+ "devices" : [
+ {
+ "connection" : {
+
+ },
+ "id" : 7,
+ "machineIdentifier" : "mock-device-7",
+ "title" : "scrump-toggins’s device"
+ }
+ ],
+ "id" : 17,
+ "username" : "scrump-toggins"
+ },
+ {
+ "avatar" : "/mock/avatars/thebaumer.png",
+ "devices" : [
+ {
+ "connection" : {
+ "address" : "192.168.9.8",
+ "local" : false,
+ "relayed" : false,
+ "remotePublicAddress" : "203.0.113.108",
+ "resolvedLocation" : "New York, NY"
+ },
+ "id" : 8,
+ "machineIdentifier" : "thebaumer-netscape",
+ "platform" : "Mac OS 9",
+ "product" : "Plex Web",
+ "title" : "Netscape Navigator"
+ }
+ ],
+ "id" : 18,
+ "username" : "TheBaumer"
+ },
+ {
+ "avatar" : "/mock/avatars/le0n.png",
+ "devices" : [
+ {
+ "connection" : {
+ "remotePublicAddress" : "203.0.113.109",
+ "resolvedLocation" : "Manhattan, NY"
+ },
+ "id" : 9,
+ "machineIdentifier" : "le0n-sony-watchman",
+ "platform" : "Analog TV",
+ "title" : "Sony Watchman"
+ }
+ ],
+ "id" : 19,
+ "username" : "Le0n"
+ },
+ {
+ "avatar" : "/mock/avatars/joi.png",
+ "devices" : [
+ {
+ "connection" : {
+ "remotePublicAddress" : "203.0.113.110",
+ "resolvedLocation" : "Los Angeles, CA"
+ },
+ "id" : 10,
+ "machineIdentifier" : "joi-wallace-emanator",
+ "platform" : "Wallace OS",
+ "product" : "Plex for Emanator",
+ "title" : "Wallace Emanator"
+ }
+ ],
+ "id" : 20,
+ "username" : "Joi"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Back.png b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Back.png
new file mode 100644
index 0000000..457f68a
Binary files /dev/null and b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Back.png differ
diff --git a/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json
new file mode 100644
index 0000000..1784414
--- /dev/null
+++ b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "Back.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Contents.json b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Contents.json
new file mode 100644
index 0000000..3d73e5f
--- /dev/null
+++ b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Contents.json
@@ -0,0 +1,14 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "layers" : [
+ {
+ "filename" : "Front.imagestacklayer"
+ },
+ {
+ "filename" : "Back.imagestacklayer"
+ }
+ ]
+}
diff --git a/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json
new file mode 100644
index 0000000..ebd57fa
--- /dev/null
+++ b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "Front.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Front.png b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Front.png
new file mode 100644
index 0000000..158bde9
Binary files /dev/null and b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Front.png differ
diff --git a/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back.png b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back.png
new file mode 100644
index 0000000..279b660
Binary files /dev/null and b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back.png differ
diff --git a/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json
new file mode 100644
index 0000000..1784414
--- /dev/null
+++ b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "Back.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon.imagestack/Contents.json b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon.imagestack/Contents.json
new file mode 100644
index 0000000..3d73e5f
--- /dev/null
+++ b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon.imagestack/Contents.json
@@ -0,0 +1,14 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "layers" : [
+ {
+ "filename" : "Front.imagestacklayer"
+ },
+ {
+ "filename" : "Back.imagestacklayer"
+ }
+ ]
+}
diff --git a/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json
new file mode 100644
index 0000000..ebd57fa
--- /dev/null
+++ b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "Front.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Front.png b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Front.png
new file mode 100644
index 0000000..1764a79
Binary files /dev/null and b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Front.png differ
diff --git a/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/Contents.json b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/Contents.json
new file mode 100644
index 0000000..99410e3
--- /dev/null
+++ b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/Contents.json
@@ -0,0 +1,32 @@
+{
+ "assets" : [
+ {
+ "filename" : "App Icon.imagestack",
+ "idiom" : "tv",
+ "role" : "primary-app-icon",
+ "size" : "400x240"
+ },
+ {
+ "filename" : "App Icon - App Store.imagestack",
+ "idiom" : "tv",
+ "role" : "primary-app-icon",
+ "size" : "1280x768"
+ },
+ {
+ "filename" : "Top Shelf Image.imageset",
+ "idiom" : "tv",
+ "role" : "top-shelf-image",
+ "size" : "1920x720"
+ },
+ {
+ "filename" : "Top Shelf Image Wide.imageset",
+ "idiom" : "tv",
+ "role" : "top-shelf-image-wide",
+ "size" : "2320x720"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/Top Shelf Image Wide.imageset/Contents.json b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/Top Shelf Image Wide.imageset/Contents.json
new file mode 100644
index 0000000..0bd9570
--- /dev/null
+++ b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/Top Shelf Image Wide.imageset/Contents.json
@@ -0,0 +1,18 @@
+{
+ "images": [
+ {
+ "filename": "Top Shelf Wide.png",
+ "idiom": "tv",
+ "scale": "1x"
+ },
+ {
+ "filename": "Top Shelf Wide@2x.png",
+ "idiom": "tv",
+ "scale": "2x"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
diff --git a/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/Top Shelf Image Wide.imageset/Top Shelf Wide.png b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/Top Shelf Image Wide.imageset/Top Shelf Wide.png
new file mode 100644
index 0000000..18a29ea
Binary files /dev/null and b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/Top Shelf Image Wide.imageset/Top Shelf Wide.png differ
diff --git a/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/Top Shelf Image Wide.imageset/Top Shelf Wide@2x.png b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/Top Shelf Image Wide.imageset/Top Shelf Wide@2x.png
new file mode 100644
index 0000000..9405017
Binary files /dev/null and b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/Top Shelf Image Wide.imageset/Top Shelf Wide@2x.png differ
diff --git a/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/Top Shelf Image.imageset/Contents.json b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/Top Shelf Image.imageset/Contents.json
new file mode 100644
index 0000000..3c48ef5
--- /dev/null
+++ b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/Top Shelf Image.imageset/Contents.json
@@ -0,0 +1,18 @@
+{
+ "images": [
+ {
+ "filename": "Top Shelf.png",
+ "idiom": "tv",
+ "scale": "1x"
+ },
+ {
+ "filename": "Top Shelf@2x.png",
+ "idiom": "tv",
+ "scale": "2x"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
diff --git a/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/Top Shelf Image.imageset/Top Shelf.png b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/Top Shelf Image.imageset/Top Shelf.png
new file mode 100644
index 0000000..f00a9f5
Binary files /dev/null and b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/Top Shelf Image.imageset/Top Shelf.png differ
diff --git a/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/Top Shelf Image.imageset/Top Shelf@2x.png b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/Top Shelf Image.imageset/Top Shelf@2x.png
new file mode 100644
index 0000000..54f6aa6
Binary files /dev/null and b/PlexBar/Resources/PlexBarTVAssets.xcassets/AppIcon.brandassets/Top Shelf Image.imageset/Top Shelf@2x.png differ
diff --git a/PlexBar/Resources/PlexBarTVAssets.xcassets/Contents.json b/PlexBar/Resources/PlexBarTVAssets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/PlexBar/Resources/PlexBarTVAssets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/PlexBar/Services/PlexAPIClient+CollectionPlaylistMutations.swift b/PlexBar/Services/PlexAPIClient+CollectionPlaylistMutations.swift
new file mode 100644
index 0000000..522d564
--- /dev/null
+++ b/PlexBar/Services/PlexAPIClient+CollectionPlaylistMutations.swift
@@ -0,0 +1,244 @@
+import PlexModels
+import Foundation
+
+extension PlexAPIClient {
+ func createCollection(
+ title: String,
+ libraryID: String,
+ metadataTypeID: Int,
+ endpointPath: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> PlexMediaItem {
+ let title = try validatedMediaTitle(title)
+ let data = try await performMediaMutation(
+ path: endpointPath,
+ method: "POST",
+ queryItems: [
+ URLQueryItem(name: "sectionId", value: libraryID),
+ URLQueryItem(name: "title", value: title),
+ URLQueryItem(name: "smart", value: "0"),
+ URLQueryItem(name: "type", value: String(metadataTypeID)),
+ ],
+ configuration: configuration
+ )
+ return try decodedMutationItem(from: data)
+ }
+
+ func renameCollection(
+ id: String,
+ title: String,
+ metadataEndpointPath: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws {
+ _ = try await performMediaMutation(
+ path: metadataEndpointPath,
+ appendingPathComponents: [id],
+ method: "PUT",
+ queryItems: [URLQueryItem(name: "title", value: try validatedMediaTitle(title))],
+ configuration: configuration
+ )
+ }
+
+ func deleteCollection(
+ id: String,
+ libraryID: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws {
+ _ = try await performMediaMutation(
+ path: "/library/sections/\(libraryID)/collection/\(id)",
+ method: "DELETE",
+ configuration: configuration
+ )
+ }
+
+ func addItem(
+ uri: String,
+ toCollectionID collectionID: String,
+ endpointPath: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws {
+ _ = try await performMediaMutation(
+ path: endpointPath,
+ appendingPathComponents: [collectionID, "items"],
+ method: "PUT",
+ queryItems: [URLQueryItem(name: "uri", value: uri)],
+ configuration: configuration
+ )
+ }
+
+ func removeCollectionItem(
+ id: String,
+ fromCollectionID collectionID: String,
+ endpointPath: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws {
+ _ = try await performMediaMutation(
+ path: endpointPath,
+ appendingPathComponents: [collectionID, "items", id],
+ method: "PUT",
+ configuration: configuration
+ )
+ }
+
+ func moveCollectionItem(
+ id: String,
+ inCollectionID collectionID: String,
+ afterItemID: String?,
+ endpointPath: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws {
+ _ = try await performMediaMutation(
+ path: endpointPath,
+ appendingPathComponents: [collectionID, "items", id, "move"],
+ method: "PUT",
+ queryItems: afterItemID.map { [URLQueryItem(name: "after", value: $0)] } ?? [],
+ configuration: configuration
+ )
+ }
+
+ func renamePlaylist(
+ id: String,
+ title: String,
+ endpointPath: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws {
+ _ = try await performMediaMutation(
+ path: endpointPath,
+ appendingPathComponents: [id],
+ method: "PUT",
+ queryItems: [URLQueryItem(name: "title", value: try validatedMediaTitle(title))],
+ configuration: configuration
+ )
+ }
+
+ func createPlaylist(
+ containingItemURI uri: String,
+ endpointPath: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> PlexMediaItem {
+ let data = try await performMediaMutation(
+ path: endpointPath,
+ method: "POST",
+ queryItems: [URLQueryItem(name: "uri", value: uri)],
+ configuration: configuration
+ )
+ return try decodedMutationItem(from: data)
+ }
+
+ func addItem(
+ uri: String,
+ toPlaylistID playlistID: String,
+ endpointPath: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws {
+ _ = try await performMediaMutation(
+ path: endpointPath,
+ appendingPathComponents: [playlistID, "items"],
+ method: "PUT",
+ queryItems: [URLQueryItem(name: "uri", value: uri)],
+ configuration: configuration
+ )
+ }
+
+ func deletePlaylist(
+ id: String,
+ endpointPath: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws {
+ _ = try await performMediaMutation(
+ path: endpointPath,
+ appendingPathComponents: [id],
+ method: "DELETE",
+ configuration: configuration
+ )
+ }
+
+ func removePlaylistItem(
+ playlistItemID: String,
+ fromPlaylistID playlistID: String,
+ endpointPath: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws {
+ _ = try await performMediaMutation(
+ path: endpointPath,
+ appendingPathComponents: [playlistID, "items", playlistItemID],
+ method: "DELETE",
+ configuration: configuration
+ )
+ }
+
+ func movePlaylistItem(
+ playlistItemID: String,
+ inPlaylistID playlistID: String,
+ afterPlaylistItemID: String?,
+ endpointPath: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws {
+ _ = try await performMediaMutation(
+ path: endpointPath,
+ appendingPathComponents: [playlistID, "items", playlistItemID, "move"],
+ method: "PUT",
+ queryItems: afterPlaylistItemID.map { [URLQueryItem(name: "after", value: $0)] } ?? [],
+ configuration: configuration
+ )
+ }
+}
+
+private extension PlexAPIClient {
+ func performMediaMutation(
+ path: String,
+ appendingPathComponents: [String] = [],
+ method: String,
+ queryItems: [URLQueryItem] = [],
+ configuration: PlexConnectionConfiguration
+ ) async throws -> Data {
+ let endpoint = appendingPathComponents.isEmpty
+ ? PlexURLBuilder.endpointURL(serverURL: configuration.serverURL, path: path)
+ : PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: path,
+ appendingPathComponents: appendingPathComponents
+ )
+ guard let endpoint,
+ var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else {
+ throw PlexAPIError.invalidServerURL
+ }
+ if !queryItems.isEmpty {
+ components.queryItems = (components.queryItems ?? []) + queryItems
+ }
+ guard let url = components.url else {
+ throw PlexAPIError.invalidServerURL
+ }
+ let request = PlexRequestBuilder(clientContext: configuration.clientContext).request(
+ url: url,
+ method: method,
+ accept: "application/json",
+ token: configuration.token
+ )
+ return try await data(for: request)
+ }
+
+ func validatedMediaTitle(_ title: String) throws -> String {
+ guard let title = title.nilIfBlank else {
+ throw PlexAPIError.invalidMediaTitle
+ }
+ return title
+ }
+
+ func decodedMutationItem(from data: Data) throws -> PlexMediaItem {
+ do {
+ guard let item = try JSONDecoder()
+ .decode(PlexMediaEnvelope.self, from: data)
+ .mediaContainer
+ .metadata
+ .first else {
+ throw PlexAPIError.invalidResponse
+ }
+ return item
+ } catch let error as PlexAPIError {
+ throw error
+ } catch {
+ throw PlexAPIError.decodingFailed(error)
+ }
+ }
+}
diff --git a/PlexBar/Services/PlexAPIClient+DownloadArtwork.swift b/PlexBar/Services/PlexAPIClient+DownloadArtwork.swift
new file mode 100644
index 0000000..4d5cd57
--- /dev/null
+++ b/PlexBar/Services/PlexAPIClient+DownloadArtwork.swift
@@ -0,0 +1,23 @@
+import Foundation
+
+extension PlexAPIClient {
+ func fetchDownloadArtwork(
+ path: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> Data {
+ guard let url = PlexURLBuilder.transcodedArtworkURL(
+ serverURL: configuration.serverURL,
+ path: path,
+ width: 480,
+ height: 720
+ ) else {
+ throw PlexAPIError.invalidServerURL
+ }
+ let request = PlexRequestBuilder(clientContext: configuration.clientContext).request(
+ url: url,
+ accept: "image/jpeg",
+ token: configuration.token
+ )
+ return try await data(for: request)
+ }
+}
diff --git a/PlexBar/Services/PlexAPIClient+DownloadQueue.swift b/PlexBar/Services/PlexAPIClient+DownloadQueue.swift
new file mode 100644
index 0000000..d8a8912
--- /dev/null
+++ b/PlexBar/Services/PlexAPIClient+DownloadQueue.swift
@@ -0,0 +1,394 @@
+import PlexModels
+import Foundation
+
+extension PlexAPIClient {
+ func fetchOrCreateDownloadQueue(
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> PlexDownloadQueue {
+ try await downloadQueue(
+ pathComponents: [],
+ method: "POST",
+ using: configuration
+ )
+ }
+
+ func fetchDownloadQueue(
+ queueID: Int,
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> PlexDownloadQueue {
+ guard queueID > 0 else {
+ throw PlexAPIError.invalidDownloadQueue
+ }
+ return try await downloadQueue(
+ pathComponents: [String(queueID)],
+ method: "GET",
+ using: configuration
+ )
+ }
+
+ func addToDownloadQueue(
+ keys: [String],
+ queueID: Int,
+ decision: PlexDownloadDecisionParameters,
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> [PlexAddedDownloadQueueItem] {
+ guard queueID > 0,
+ !keys.isEmpty,
+ keys.allSatisfy(Self.isValidDownloadMetadataKey),
+ Self.isValid(decision: decision),
+ let endpoint = downloadQueueURL(
+ pathComponents: [String(queueID), "add"],
+ using: configuration
+ ),
+ var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else {
+ throw PlexAPIError.invalidDownloadQueue
+ }
+
+ components.queryItems = [
+ URLQueryItem(name: "keys", value: keys.joined(separator: ","))
+ ] + downloadDecisionQueryItems(decision)
+ guard let url = components.url else {
+ throw PlexAPIError.invalidServerURL
+ }
+
+ var request = downloadQueueRequest(
+ url: url,
+ method: "POST",
+ using: configuration
+ )
+ applyDownloadDecisionHeaders(decision, to: &request)
+ let data = try await data(for: request)
+ do {
+ return try JSONDecoder()
+ .decode(PlexAddedDownloadQueueItemsEnvelope.self, from: data)
+ .mediaContainer.items
+ } catch {
+ throw PlexAPIError.decodingFailed(error)
+ }
+ }
+
+ func fetchDownloadQueueItems(
+ queueID: Int,
+ itemIDs: [Int]? = nil,
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> [PlexDownloadQueueItem] {
+ guard queueID > 0,
+ itemIDs?.isEmpty != true,
+ itemIDs?.allSatisfy({ $0 > 0 }) != false else {
+ throw PlexAPIError.invalidDownloadQueue
+ }
+ var pathComponents = [String(queueID), "items"]
+ if let itemIDs {
+ pathComponents.append(itemIDs.map(String.init).joined(separator: ","))
+ }
+ guard let endpoint = downloadQueueURL(
+ pathComponents: pathComponents,
+ using: configuration
+ ) else {
+ throw PlexAPIError.invalidServerURL
+ }
+
+ let request = downloadQueueRequest(url: endpoint, using: configuration)
+ let data = try await data(for: request)
+ do {
+ return try JSONDecoder()
+ .decode(PlexDownloadQueueItemsEnvelope.self, from: data)
+ .mediaContainer.items
+ } catch {
+ throw PlexAPIError.decodingFailed(error)
+ }
+ }
+
+ func deleteDownloadQueueItems(
+ queueID: Int,
+ itemIDs: [Int],
+ using configuration: PlexConnectionConfiguration
+ ) async throws {
+ try await mutateDownloadQueueItems(
+ queueID: queueID,
+ itemIDs: itemIDs,
+ suffix: [],
+ method: "DELETE",
+ using: configuration
+ )
+ }
+
+ func restartDownloadQueueItems(
+ queueID: Int,
+ itemIDs: [Int],
+ using configuration: PlexConnectionConfiguration
+ ) async throws {
+ try await mutateDownloadQueueItems(
+ queueID: queueID,
+ itemIDs: itemIDs,
+ suffix: ["restart"],
+ method: "POST",
+ using: configuration
+ )
+ }
+
+ func fetchDownloadQueueDecision(
+ queueID: Int,
+ itemID: Int,
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> PlexDownloadQueueDecision {
+ (try await fetchDownloadQueueDecisionDocument(
+ queueID: queueID,
+ itemID: itemID,
+ using: configuration
+ )).decision
+ }
+
+ func fetchDownloadQueueDecisionDocument(
+ queueID: Int,
+ itemID: Int,
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> PlexDownloadQueueDecisionDocument {
+ guard let endpoint = downloadQueueItemURL(
+ queueID: queueID,
+ itemID: itemID,
+ suffix: "decision",
+ using: configuration
+ ) else {
+ throw PlexAPIError.invalidDownloadQueue
+ }
+ let request = downloadQueueRequest(url: endpoint, using: configuration)
+ let data = try await data(for: request)
+ do {
+ let decision = try JSONDecoder()
+ .decode(PlexDownloadQueueDecisionEnvelope.self, from: data)
+ .mediaContainer
+ return PlexDownloadQueueDecisionDocument(decision: decision, data: data)
+ } catch {
+ throw PlexAPIError.decodingFailed(error)
+ }
+ }
+
+ func downloadQueueMediaRequest(
+ queueID: Int,
+ itemID: Int,
+ using configuration: PlexConnectionConfiguration
+ ) throws -> URLRequest {
+ guard let endpoint = downloadQueueItemURL(
+ queueID: queueID,
+ itemID: itemID,
+ suffix: "media",
+ using: configuration
+ ) else {
+ throw PlexAPIError.invalidDownloadQueue
+ }
+ return downloadQueueRequest(
+ url: endpoint,
+ accept: nil,
+ using: configuration
+ )
+ }
+
+ private func downloadQueue(
+ pathComponents: [String],
+ method: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> PlexDownloadQueue {
+ guard let endpoint = downloadQueueURL(
+ pathComponents: pathComponents,
+ using: configuration
+ ) else {
+ throw PlexAPIError.invalidServerURL
+ }
+ let request = downloadQueueRequest(
+ url: endpoint,
+ method: method,
+ using: configuration
+ )
+ let data = try await data(for: request)
+ do {
+ let queues = try JSONDecoder()
+ .decode(PlexDownloadQueueEnvelope.self, from: data)
+ .mediaContainer.queues
+ guard queues.count == 1, let queue = queues.first else {
+ throw PlexAPIError.invalidDownloadQueue
+ }
+ return queue
+ } catch let error as PlexAPIError {
+ throw error
+ } catch {
+ throw PlexAPIError.decodingFailed(error)
+ }
+ }
+
+ private func mutateDownloadQueueItems(
+ queueID: Int,
+ itemIDs: [Int],
+ suffix: [String],
+ method: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws {
+ guard queueID > 0,
+ !itemIDs.isEmpty,
+ itemIDs.allSatisfy({ $0 > 0 }),
+ let endpoint = downloadQueueURL(
+ pathComponents: [
+ String(queueID),
+ "items",
+ itemIDs.map(String.init).joined(separator: ","),
+ ] + suffix,
+ using: configuration
+ ) else {
+ throw PlexAPIError.invalidDownloadQueue
+ }
+ let request = downloadQueueRequest(
+ url: endpoint,
+ method: method,
+ using: configuration
+ )
+ _ = try await responseData(for: request)
+ }
+
+ private func downloadQueueItemURL(
+ queueID: Int,
+ itemID: Int,
+ suffix: String,
+ using configuration: PlexConnectionConfiguration
+ ) -> URL? {
+ guard queueID > 0, itemID > 0 else {
+ return nil
+ }
+ return downloadQueueURL(
+ pathComponents: [String(queueID), "item", String(itemID), suffix],
+ using: configuration
+ )
+ }
+
+ private func downloadQueueURL(
+ pathComponents: [String],
+ using configuration: PlexConnectionConfiguration
+ ) -> URL? {
+ guard !pathComponents.isEmpty else {
+ return PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: "/downloadQueue"
+ )
+ }
+ return PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: "/downloadQueue",
+ appendingPathComponents: pathComponents
+ )
+ }
+
+ private func downloadQueueRequest(
+ url: URL,
+ method: String = "GET",
+ accept: String? = "application/json",
+ using configuration: PlexConnectionConfiguration
+ ) -> URLRequest {
+ PlexRequestBuilder(clientContext: configuration.clientContext).request(
+ url: url,
+ method: method,
+ accept: accept,
+ token: configuration.token
+ )
+ }
+
+ private func downloadDecisionQueryItems(
+ _ decision: PlexDownloadDecisionParameters
+ ) -> [URLQueryItem] {
+ var items: [URLQueryItem] = []
+ items.appendIfPresent(name: "path", value: decision.mediaPath)
+ items.appendIfPresent(name: "mediaIndex", value: decision.mediaIndex)
+ items.appendIfPresent(name: "partIndex", value: decision.partIndex)
+ items.appendIfPresent(name: "protocol", value: decision.deliveryProtocol?.rawValue)
+ items.appendIfPresent(name: "directPlay", value: decision.allowsDirectPlay)
+ items.appendIfPresent(name: "directStream", value: decision.allowsDirectStream)
+ items.appendIfPresent(name: "directStreamAudio", value: decision.allowsDirectStreamAudio)
+ items.appendIfPresent(name: "subtitles", value: decision.subtitleMode?.rawValue)
+ items.appendIfPresent(name: "advancedSubtitles", value: decision.advancedSubtitleMode?.rawValue)
+ items.appendIfPresent(name: "videoBitrate", value: decision.videoBitrate)
+ items.appendIfPresent(name: "videoQuality", value: decision.videoQuality)
+ items.appendIfPresent(name: "videoResolution", value: decision.videoResolution)
+ items.appendIfPresent(name: "musicBitrate", value: decision.musicBitrate)
+ return items
+ }
+
+ private func applyDownloadDecisionHeaders(
+ _ decision: PlexDownloadDecisionParameters,
+ to request: inout URLRequest
+ ) {
+ request.setValue(
+ decision.sessionIdentifier?.nilIfBlank,
+ forHTTPHeaderField: "X-Plex-Session-Identifier"
+ )
+ request.setValue(
+ decision.clientProfileName?.nilIfBlank,
+ forHTTPHeaderField: "X-Plex-Client-Profile-Name"
+ )
+ request.setValue(
+ decision.clientProfileExtra?.nilIfBlank,
+ forHTTPHeaderField: "X-Plex-Client-Profile-Extra"
+ )
+ }
+
+ private static func isValidDownloadMetadataKey(_ key: String) -> Bool {
+ guard let key = key.nilIfBlank,
+ key.hasPrefix("/library/metadata/") else {
+ return false
+ }
+ return URL(string: key)?.scheme == nil && URL(string: key)?.host == nil
+ }
+
+ private static func isValid(decision: PlexDownloadDecisionParameters) -> Bool {
+ if let path = decision.mediaPath,
+ !isValidDownloadMetadataKey(path) {
+ return false
+ }
+ if let mediaIndex = decision.mediaIndex, mediaIndex < -1 {
+ return false
+ }
+ if let partIndex = decision.partIndex, partIndex < -1 {
+ return false
+ }
+ let nonnegativeValues = [
+ decision.videoBitrate,
+ decision.musicBitrate,
+ ].compactMap { $0 }
+ guard nonnegativeValues.allSatisfy({ $0 >= 0 }) else {
+ return false
+ }
+ if let videoQuality = decision.videoQuality,
+ !(0...99).contains(videoQuality) {
+ return false
+ }
+ if let resolution = decision.videoResolution,
+ !isValidDownloadResolution(resolution) {
+ return false
+ }
+ return true
+ }
+
+ private static func isValidDownloadResolution(_ value: String) -> Bool {
+ let components = value.split(whereSeparator: { $0 == "x" || $0 == ":" })
+ guard components.count == 2,
+ let width = Int(components[0]), width > 0,
+ let height = Int(components[1]), height > 0 else {
+ return false
+ }
+ return true
+ }
+}
+
+private extension Array where Element == URLQueryItem {
+ mutating func appendIfPresent(name: String, value: String?) {
+ guard let value = value?.nilIfBlank else { return }
+ append(URLQueryItem(name: name, value: value))
+ }
+
+ mutating func appendIfPresent(name: String, value: Int?) {
+ guard let value else { return }
+ append(URLQueryItem(name: name, value: String(value)))
+ }
+
+ mutating func appendIfPresent(name: String, value: Bool?) {
+ guard let value else { return }
+ append(URLQueryItem(name: name, value: value ? "1" : "0"))
+ }
+}
diff --git a/PlexBar/Services/PlexAPIClient+LibraryBrowse.swift b/PlexBar/Services/PlexAPIClient+LibraryBrowse.swift
new file mode 100644
index 0000000..6658d05
--- /dev/null
+++ b/PlexBar/Services/PlexAPIClient+LibraryBrowse.swift
@@ -0,0 +1,185 @@
+import PlexModels
+import Foundation
+
+extension PlexAPIClient {
+ func fetchCollectionsPage(
+ libraryID: String,
+ using configuration: PlexConnectionConfiguration,
+ start: Int = 0,
+ size: Int = 100
+ ) async throws -> PlexMediaPage {
+ try await fetchMediaPage(
+ contentPath: "/library/sections/\(libraryID)/collections",
+ using: configuration,
+ start: start,
+ size: size
+ )
+ }
+
+ func fetchPlaylistsPage(
+ endpointPath: String,
+ using configuration: PlexConnectionConfiguration,
+ start: Int = 0,
+ size: Int = 100
+ ) async throws -> PlexMediaPage {
+ try await fetchMediaPage(
+ contentPath: endpointPath,
+ using: configuration,
+ start: start,
+ size: size
+ )
+ }
+
+ func fetchLibraryBrowseDefinition(
+ sectionPath: String,
+ contentPath: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> PlexLibraryBrowseDefinition {
+ async let types = fetchLibraryBrowseTypes(
+ sectionPath: sectionPath,
+ using: configuration
+ )
+ async let filters = fetchLibraryFilters(
+ sectionPath: sectionPath,
+ using: configuration
+ )
+ async let sorts = fetchLibrarySorts(
+ sectionPath: sectionPath,
+ using: configuration
+ )
+
+ return try await PlexLibraryBrowseDefinition(
+ contentPath: contentPath,
+ filters: filters,
+ sorts: sorts,
+ types: types
+ )
+ }
+
+ func fetchLibraryFilterValues(
+ for definition: PlexLibraryFilterDefinition,
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> [PlexLibraryFilterValue] {
+ guard let valuesPath = definition.valuesPath else {
+ throw PlexAPIError.invalidResponse
+ }
+ let data = try await fetchLibraryDescriptorData(
+ path: valuesPath,
+ using: configuration
+ )
+
+ do {
+ return try JSONDecoder()
+ .decode(PlexLibraryFilterValuesEnvelope.self, from: data)
+ .mediaContainer
+ .values(for: definition)
+ } catch let error as PlexAPIError {
+ throw error
+ } catch {
+ throw PlexAPIError.decodingFailed(error)
+ }
+ }
+}
+
+private extension PlexAPIClient {
+ func fetchLibraryBrowseTypes(
+ sectionPath: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> [PlexLibraryBrowseType] {
+ guard var components = URLComponents(string: sectionPath) else {
+ throw PlexAPIError.invalidServerURL
+ }
+ components.queryItems = (components.queryItems ?? [])
+ + [URLQueryItem(name: "includeDetails", value: "1")]
+ guard let path = components.string else {
+ throw PlexAPIError.invalidServerURL
+ }
+ let data = try await fetchLibraryDescriptorData(path: path, using: configuration)
+ do {
+ return try JSONDecoder().decode(PlexLibraryBrowseEnvelope.self, from: data)
+ .mediaContainer.types
+ } catch {
+ throw PlexAPIError.decodingFailed(error)
+ }
+ }
+
+ func fetchLibraryFilters(
+ sectionPath: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> [PlexLibraryFilterDefinition] {
+ let data = try await fetchLibraryDescriptorData(
+ sectionPath: sectionPath,
+ descriptor: "filters",
+ using: configuration
+ )
+
+ do {
+ return try JSONDecoder()
+ .decode(PlexLibraryFilterEnvelope.self, from: data)
+ .mediaContainer
+ .filters
+ } catch {
+ throw PlexAPIError.decodingFailed(error)
+ }
+ }
+
+ func fetchLibrarySorts(
+ sectionPath: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> [PlexLibrarySortDefinition] {
+ let data = try await fetchLibraryDescriptorData(
+ sectionPath: sectionPath,
+ descriptor: "sorts",
+ using: configuration
+ )
+
+ do {
+ return try JSONDecoder()
+ .decode(PlexLibrarySortEnvelope.self, from: data)
+ .mediaContainer
+ .sorts
+ } catch {
+ throw PlexAPIError.decodingFailed(error)
+ }
+ }
+
+ func fetchLibraryDescriptorData(
+ sectionPath: String,
+ descriptor: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> Data {
+ guard let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: sectionPath,
+ appendingPathComponent: descriptor
+ ) else {
+ throw PlexAPIError.invalidServerURL
+ }
+
+ let request = PlexRequestBuilder(clientContext: configuration.clientContext).request(
+ url: endpoint,
+ accept: "application/json",
+ token: configuration.token
+ )
+ return try await data(for: request)
+ }
+
+ func fetchLibraryDescriptorData(
+ path: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> Data {
+ guard let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: path
+ ) else {
+ throw PlexAPIError.invalidServerURL
+ }
+
+ let request = PlexRequestBuilder(clientContext: configuration.clientContext).request(
+ url: endpoint,
+ accept: "application/json",
+ token: configuration.token
+ )
+ return try await data(for: request)
+ }
+}
diff --git a/PlexBar/Services/PlexAPIClient+Media.swift b/PlexBar/Services/PlexAPIClient+Media.swift
new file mode 100644
index 0000000..a821fb4
--- /dev/null
+++ b/PlexBar/Services/PlexAPIClient+Media.swift
@@ -0,0 +1,626 @@
+import PlexModels
+import Foundation
+import OSLog
+
+private let plexMediaAPILogger = Logger(
+ subsystem: "com.crapshack.PlexBar",
+ category: "PlexAPIClient.Media"
+)
+
+extension PlexAPIClient {
+ func fetchMediaPage(
+ libraryID: String,
+ using configuration: PlexConnectionConfiguration,
+ start: Int = 0,
+ size: Int = 100,
+ searchQuery: String? = nil,
+ browseOptions: PlexLibraryBrowseOptions = .default
+ ) async throws -> PlexMediaPage {
+ try await fetchMediaPage(
+ contentPath: "/library/sections/\(libraryID)/all",
+ using: configuration,
+ start: start,
+ size: size,
+ searchQuery: searchQuery,
+ browseOptions: browseOptions
+ )
+ }
+
+ func fetchMediaPage(
+ contentPath: String,
+ using configuration: PlexConnectionConfiguration,
+ start: Int = 0,
+ size: Int = 100,
+ searchQuery: String? = nil,
+ browseOptions: PlexLibraryBrowseOptions = .default
+ ) async throws -> PlexMediaPage {
+ let safeStart = max(start, 0)
+ guard let url = mediaPageURL(
+ contentPath: contentPath,
+ configuration: configuration,
+ searchQuery: searchQuery,
+ browseOptions: browseOptions
+ ) else {
+ throw PlexAPIError.invalidServerURL
+ }
+
+ return try await fetchMediaPage(
+ url: url,
+ configuration: configuration,
+ start: safeStart,
+ size: size
+ )
+ }
+
+ func fetchMediaChildren(
+ of item: PlexMediaItem,
+ using configuration: PlexConnectionConfiguration,
+ start: Int = 0,
+ size: Int = 100
+ ) async throws -> PlexMediaPage {
+ guard let path = item.childrenPath,
+ let url = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: path
+ ) else {
+ throw PlexAPIError.invalidResponse
+ }
+
+ return try await fetchMediaPage(
+ url: url,
+ configuration: configuration,
+ start: max(start, 0),
+ size: size
+ )
+ }
+
+ private func fetchMediaPage(
+ url: URL,
+ configuration: PlexConnectionConfiguration,
+ start: Int,
+ size: Int
+ ) async throws -> PlexMediaPage {
+
+ var request = authenticatedRequest(url: url, configuration: configuration)
+ request.setValue(String(start), forHTTPHeaderField: "X-Plex-Container-Start")
+ request.setValue(String(max(size, 1)), forHTTPHeaderField: "X-Plex-Container-Size")
+ let (data, response) = try await responseData(for: request)
+
+ do {
+ let decoded = try JSONDecoder().decode(PlexMediaEnvelope.self, from: data)
+ return PlexMediaPage(
+ items: decoded.mediaContainer.metadata,
+ offset: decoded.mediaContainer.offset ?? start,
+ totalSize: totalSize(from: response) ?? decoded.mediaContainer.totalSize
+ )
+ } catch {
+ throw PlexAPIError.decodingFailed(error)
+ }
+ }
+
+ func fetchMediaMetadata(
+ ratingKey: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> PlexMediaItem {
+ try await fetchMediaMetadata(
+ path: "/library/metadata/\(ratingKey)?includeOptionalElements=Chapter,Image,Marker,Rating&includeGuids=1",
+ using: configuration
+ )
+ }
+
+ func fetchMediaMetadata(
+ path: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> PlexMediaItem {
+ guard let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: path
+ ) else {
+ throw PlexAPIError.invalidServerURL
+ }
+
+ let responseData = try await data(for: authenticatedRequest(url: endpoint, configuration: configuration))
+ do {
+ let decoded = try JSONDecoder().decode(PlexMediaEnvelope.self, from: responseData)
+ guard let item = decoded.mediaContainer.metadata.first else {
+ throw PlexAPIError.invalidResponse
+ }
+ return item
+ } catch let error as PlexAPIError {
+ throw error
+ } catch {
+ throw PlexAPIError.decodingFailed(error)
+ }
+ }
+
+ func fetchHomeHubs(
+ endpoints: PlexLibraryProviderEndpoints,
+ using configuration: PlexConnectionConfiguration,
+ count: Int = 20
+ ) async throws -> [PlexHub] {
+ guard let promotedPath = endpoints.promotedPath else {
+ throw PlexAPIError.missingLibraryPromotedFeature
+ }
+ guard let continueWatchingPath = endpoints.continueWatchingPath else {
+ throw PlexAPIError.missingLibraryContinueWatchingFeature
+ }
+ async let promoted = fetchHubs(endpointPath: promotedPath, using: configuration, count: count)
+ async let continuation = fetchHubs(endpointPath: continueWatchingPath, using: configuration, count: count)
+ return try await PlexHub.homeHubs(promoted: promoted, continueWatching: continuation)
+ }
+
+ func fetchHubs(
+ endpointPath: String,
+ using configuration: PlexConnectionConfiguration,
+ count: Int = 20
+ ) async throws -> [PlexHub] {
+ guard let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: endpointPath
+ ), var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else {
+ throw PlexAPIError.invalidServerURL
+ }
+ var queryItems = components.queryItems ?? []
+ queryItems.removeAll { $0.name == "count" }
+ queryItems.append(URLQueryItem(name: "count", value: String(max(count, 1))))
+ components.queryItems = queryItems
+
+ guard let url = components.url else {
+ throw PlexAPIError.invalidServerURL
+ }
+
+ let responseData = try await data(for: authenticatedRequest(url: url, configuration: configuration))
+ do {
+ let decoded = try JSONDecoder().decode(PlexHubEnvelope.self, from: responseData)
+ return decoded.mediaContainer.hubs
+ } catch {
+ throw PlexAPIError.decodingFailed(error)
+ }
+ }
+
+ func fetchSearchHubs(
+ query: String,
+ endpointPath: String,
+ using configuration: PlexConnectionConfiguration,
+ limit: Int = 12
+ ) async throws -> [PlexHub] {
+ let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !normalizedQuery.isEmpty else {
+ return []
+ }
+ guard let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: endpointPath
+ ), var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else {
+ throw PlexAPIError.invalidServerURL
+ }
+ var queryItems = components.queryItems ?? []
+ queryItems.removeAll { $0.name == "query" || $0.name == "limit" }
+ queryItems += [
+ URLQueryItem(name: "query", value: normalizedQuery),
+ URLQueryItem(name: "limit", value: String(max(limit, 1))),
+ ]
+ components.queryItems = queryItems
+
+ guard let url = components.url else {
+ throw PlexAPIError.invalidServerURL
+ }
+
+ let responseData = try await data(
+ for: authenticatedRequest(url: url, configuration: configuration)
+ )
+ do {
+ let decoded = try JSONDecoder().decode(PlexHubEnvelope.self, from: responseData)
+ return decoded.mediaContainer.hubs.filter { !$0.metadata.isEmpty }
+ } catch {
+ plexMediaAPILogger.error(
+ "Global search response decoding failed: \(String(describing: error), privacy: .public)"
+ )
+ throw PlexAPIError.decodingFailed(error)
+ }
+ }
+
+ func fetchRelatedHubs(
+ ratingKey: String,
+ using configuration: PlexConnectionConfiguration,
+ count: Int = 12
+ ) async throws -> [PlexHub] {
+ guard let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: "/hubs/metadata/\(ratingKey)/related"
+ ), var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else {
+ throw PlexAPIError.invalidServerURL
+ }
+ components.queryItems = [URLQueryItem(name: "count", value: String(max(count, 1)))]
+
+ guard let url = components.url else {
+ throw PlexAPIError.invalidServerURL
+ }
+
+ let responseData = try await data(
+ for: authenticatedRequest(url: url, configuration: configuration)
+ )
+ do {
+ let decoded = try JSONDecoder().decode(PlexHubEnvelope.self, from: responseData)
+ return decoded.mediaContainer.hubs.filter { !$0.metadata.isEmpty }
+ } catch {
+ throw PlexAPIError.decodingFailed(error)
+ }
+ }
+
+ func fetchPostPlayHubs(
+ ratingKey: String,
+ using configuration: PlexConnectionConfiguration,
+ count: Int = 12
+ ) async throws -> [PlexHub] {
+ guard Int(ratingKey) != nil,
+ let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: "/hubs/metadata",
+ appendingPathComponents: [ratingKey, "postplay"]
+ ), var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else {
+ throw PlexAPIError.invalidServerURL
+ }
+ components.queryItems = [URLQueryItem(name: "count", value: String(max(count, 1)))]
+
+ guard let url = components.url else {
+ throw PlexAPIError.invalidServerURL
+ }
+
+ let responseData = try await data(
+ for: authenticatedRequest(url: url, configuration: configuration)
+ )
+ do {
+ let decoded = try JSONDecoder().decode(PlexHubEnvelope.self, from: responseData)
+ return decoded.mediaContainer.hubs.filter { !$0.metadata.isEmpty }
+ } catch {
+ throw PlexAPIError.decodingFailed(error)
+ }
+ }
+
+ func fetchMediaExtras(
+ ratingKey: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> [PlexMediaItem] {
+ guard let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: "/library/metadata/\(ratingKey)/extras"
+ ) else {
+ throw PlexAPIError.invalidServerURL
+ }
+
+ let responseData = try await data(
+ for: authenticatedRequest(url: endpoint, configuration: configuration)
+ )
+ do {
+ let decoded = try JSONDecoder().decode(PlexMediaEnvelope.self, from: responseData)
+ return decoded.mediaContainer.metadata
+ } catch {
+ throw PlexAPIError.decodingFailed(error)
+ }
+ }
+
+ func makePlaybackPlan(
+ for item: PlexMediaItem,
+ using configuration: PlexConnectionConfiguration,
+ capabilities: PlexPlaybackCapabilities,
+ source requestedSource: PlexPlaybackSource? = nil,
+ videoQuality: PlexVideoQuality = .original,
+ musicQuality: PlexMusicQuality = .original,
+ audioBoost: PlexAudioBoost = .none,
+ streamingPolicy: PlexPlaybackStreamingPolicy = .automatic,
+ subtitleBurnMode: PlexSubtitleBurnMode = .automatic,
+ subtitleSize: PlexSubtitleSize = .normal,
+ automaticallySyncSubtitles: Bool = true,
+ automaticallyAdjustVideoQuality: Bool = false,
+ playSmallerVideosAtOriginalQuality: Bool = true,
+ forceVideoTranscode: Bool = false,
+ startTimeOverride: TimeInterval? = nil,
+ forceServerMediaSelection: Bool = false
+ ) async throws -> PlexPlaybackPlan {
+ let source: PlexPlaybackSource
+ if let requestedSource {
+ guard item.playbackSource(mediaIndex: requestedSource.mediaIndex) == requestedSource else {
+ throw PlexAPIError.noPlayableMedia
+ }
+ source = requestedSource
+ } else if let defaultPlaybackSource = item.defaultPlaybackSource {
+ source = defaultPlaybackSource
+ } else {
+ throw PlexAPIError.noPlayableMedia
+ }
+
+ let sessionIdentifier = UUID().uuidString.lowercased()
+ let startTime = max(
+ startTimeOverride ?? TimeInterval(item.viewOffset ?? 0) / 1_000,
+ 0
+ )
+ let requestParameters = PlexPlaybackRequestParameters(
+ item: item,
+ source: source,
+ videoQuality: videoQuality,
+ musicQuality: musicQuality,
+ audioBoost: audioBoost,
+ streamingPolicy: streamingPolicy,
+ subtitleBurnMode: subtitleBurnMode,
+ subtitleSize: subtitleSize,
+ automaticallySyncSubtitles: automaticallySyncSubtitles,
+ automaticallyAdjustVideoQuality: automaticallyAdjustVideoQuality,
+ playSmallerVideosAtOriginalQuality: playSmallerVideosAtOriginalQuality,
+ forceVideoTranscode: forceVideoTranscode,
+ sessionIdentifier: sessionIdentifier,
+ startTime: startTime,
+ forceServerMediaSelection: forceServerMediaSelection
+ )
+ let mediaKind = PlexPlaybackMediaKind(media: item.media[source.mediaIndex])
+
+ if streamingPolicy.forceDirectPlay,
+ requestParameters.permitsDirectPlay,
+ let path = capabilities.directPlayPath(for: item, source: source),
+ let playbackURL = authenticatedMediaURL(
+ configuration: configuration,
+ path: path,
+ sessionIdentifier: sessionIdentifier,
+ queryItems: []
+ ) {
+ return PlexPlaybackPlan(
+ url: playbackURL,
+ method: .directPlay,
+ mediaKind: mediaKind,
+ sessionIdentifier: sessionIdentifier,
+ ratingKey: item.ratingKey,
+ duration: item.duration.map { TimeInterval($0) / 1_000 },
+ startTime: startTime,
+ source: source,
+ usesServerMediaSelection: false
+ )
+ }
+
+ let queryItems = requestParameters.queryItems
+ let decision = try await playbackDecision(
+ configuration: configuration,
+ capabilities: capabilities,
+ mediaKind: mediaKind,
+ sessionIdentifier: sessionIdentifier,
+ queryItems: queryItems
+ )
+ let selection = try playbackSelection(from: decision, mediaKind: mediaKind)
+ let playbackURL = try playbackURL(
+ selection: selection,
+ configuration: configuration,
+ capabilities: capabilities,
+ mediaKind: mediaKind,
+ sessionIdentifier: sessionIdentifier,
+ queryItems: queryItems
+ )
+
+ return PlexPlaybackPlan(
+ url: playbackURL,
+ method: selection.method,
+ mediaKind: mediaKind,
+ sessionIdentifier: sessionIdentifier,
+ ratingKey: item.ratingKey,
+ duration: item.duration.map { TimeInterval($0) / 1_000 },
+ startTime: startTime,
+ source: source,
+ usesServerMediaSelection: forceServerMediaSelection,
+ supportsAudioBoost: selection.supportsAudioBoost
+ && requestParameters.hasMultichannelAudioSource,
+ supportsSubtitleAutoSync: requestParameters.supportsSubtitleAutoSync
+ )
+ }
+
+ func selectMediaStreams(
+ partID: Int,
+ audioStreamID: Int? = nil,
+ subtitleStreamID: Int? = nil,
+ allParts: Bool = true,
+ using configuration: PlexConnectionConfiguration
+ ) async throws {
+ let parameters = PlexMediaSelectionRequestParameters(
+ partID: partID,
+ audioStreamID: audioStreamID,
+ subtitleStreamID: subtitleStreamID,
+ allParts: allParts
+ )
+ guard parameters.hasSelection else {
+ throw PlexAPIError.invalidResponse
+ }
+ guard let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: parameters.path
+ ), var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else {
+ throw PlexAPIError.invalidServerURL
+ }
+ components.queryItems = parameters.queryItems
+
+ guard let url = components.url else {
+ throw PlexAPIError.invalidServerURL
+ }
+ var request = authenticatedRequest(url: url, configuration: configuration)
+ request.httpMethod = "PUT"
+ _ = try await responseData(for: request)
+ }
+
+ func reportTimeline(
+ _ update: PlexTimelineUpdate,
+ endpointPath: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> PlexTimelineResponse {
+ guard let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: endpointPath
+ ), var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else {
+ throw PlexAPIError.invalidServerURL
+ }
+ components.queryItems = (components.queryItems ?? [])
+ + PlexTimelineRequestParameters(update: update).queryItems
+
+ guard let url = components.url else {
+ throw PlexAPIError.invalidServerURL
+ }
+
+ var request = PlexRequestBuilder(clientContext: configuration.clientContext).request(
+ url: url,
+ method: "POST",
+ accept: "application/json",
+ token: configuration.token
+ )
+ request.setValue(update.sessionIdentifier, forHTTPHeaderField: "X-Plex-Session-Identifier")
+ let responseData = try await data(for: request)
+ do {
+ return try JSONDecoder()
+ .decode(PlexTimelineResponseEnvelope.self, from: responseData)
+ .mediaContainer
+ .response
+ } catch {
+ throw PlexAPIError.decodingFailed(error)
+ }
+ }
+
+ private func mediaPageURL(
+ contentPath: String,
+ configuration: PlexConnectionConfiguration,
+ searchQuery: String?,
+ browseOptions: PlexLibraryBrowseOptions
+ ) -> URL? {
+ guard let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: contentPath
+ ), var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else {
+ return nil
+ }
+
+ var queryItems = components.queryItems ?? []
+ for queryItem in browseOptions.queryItems {
+ queryItems.removeAll { $0.name == queryItem.name }
+ queryItems.append(queryItem)
+ }
+ if let searchQuery = searchQuery?.nilIfBlank {
+ queryItems.removeAll { $0.name == "title" }
+ queryItems.append(URLQueryItem(name: "title", value: searchQuery))
+ }
+ components.queryItems = queryItems
+ return components.url
+ }
+
+ private func authenticatedRequest(
+ url: URL,
+ configuration: PlexConnectionConfiguration
+ ) -> URLRequest {
+ PlexRequestBuilder(clientContext: configuration.clientContext).request(
+ url: url,
+ accept: "application/json",
+ token: configuration.token
+ )
+ }
+
+ private func playbackDecision(
+ configuration: PlexConnectionConfiguration,
+ capabilities: PlexPlaybackCapabilities,
+ mediaKind: PlexPlaybackMediaKind,
+ sessionIdentifier: String,
+ queryItems: [URLQueryItem]
+ ) async throws -> PlexPlaybackDecisionContainer {
+ guard let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: mediaKind.decisionPath
+ ), var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else {
+ throw PlexAPIError.invalidServerURL
+ }
+ components.queryItems = queryItems
+
+ guard let url = components.url else {
+ throw PlexAPIError.invalidServerURL
+ }
+
+ var request = authenticatedRequest(url: url, configuration: configuration)
+ request.setValue(sessionIdentifier, forHTTPHeaderField: "X-Plex-Session-Identifier")
+ request.setValue("generic", forHTTPHeaderField: "X-Plex-Client-Profile-Name")
+ request.setValue(
+ capabilities.clientProfileExtra(for: mediaKind),
+ forHTTPHeaderField: "X-Plex-Client-Profile-Extra"
+ )
+
+ do {
+ let responseData = try await data(for: request)
+ return try JSONDecoder()
+ .decode(PlexPlaybackDecisionEnvelope.self, from: responseData)
+ .mediaContainer
+ } catch let error as PlexAPIError {
+ throw error
+ } catch {
+ throw PlexAPIError.decodingFailed(error)
+ }
+ }
+
+ private func playbackSelection(
+ from decision: PlexPlaybackDecisionContainer,
+ mediaKind: PlexPlaybackMediaKind
+ ) throws -> PlexPlaybackSelection {
+ switch PlexPlaybackDecisionResolver.resolve(decision, mediaKind: mediaKind) {
+ case .selected(let selection):
+ return selection
+ case .rejected(let reason):
+ throw PlexAPIError.playbackRejected(reason)
+ case .noPlayableMedia:
+ throw PlexAPIError.noPlayableMedia
+ }
+ }
+
+ private func playbackURL(
+ selection: PlexPlaybackSelection,
+ configuration: PlexConnectionConfiguration,
+ capabilities: PlexPlaybackCapabilities,
+ mediaKind: PlexPlaybackMediaKind,
+ sessionIdentifier: String,
+ queryItems: [URLQueryItem]
+ ) throws -> URL {
+ var playbackQueryItems: [URLQueryItem] = switch selection.method {
+ case .directPlay:
+ []
+ case .directStream, .transcode:
+ queryItems
+ }
+ if selection.method != .directPlay {
+ playbackQueryItems += [
+ URLQueryItem(name: "X-Plex-Client-Profile-Name", value: "generic"),
+ URLQueryItem(
+ name: "X-Plex-Client-Profile-Extra",
+ value: capabilities.clientProfileExtra(for: mediaKind)
+ )
+ ]
+ }
+
+ guard let url = authenticatedMediaURL(
+ configuration: configuration,
+ path: selection.path,
+ sessionIdentifier: sessionIdentifier,
+ queryItems: playbackQueryItems
+ ) else {
+ throw PlexAPIError.invalidServerURL
+ }
+ return url
+ }
+
+ private func authenticatedMediaURL(
+ configuration: PlexConnectionConfiguration,
+ path: String,
+ sessionIdentifier: String,
+ queryItems: [URLQueryItem]
+ ) -> URL? {
+ guard let endpoint = PlexURLBuilder.endpointURL(serverURL: configuration.serverURL, path: path),
+ var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else {
+ return nil
+ }
+
+ let clientQueryItems = configuration.clientContext.headers
+ .sorted { $0.key < $1.key }
+ .map { URLQueryItem(name: $0.key, value: $0.value) }
+ components.queryItems = queryItems + clientQueryItems + [
+ URLQueryItem(name: "X-Plex-Session-Identifier", value: sessionIdentifier),
+ URLQueryItem(name: "X-Plex-Token", value: configuration.token)
+ ]
+ return components.url
+ }
+}
diff --git a/PlexBar/Services/PlexAPIClient+MediaProvider.swift b/PlexBar/Services/PlexAPIClient+MediaProvider.swift
new file mode 100644
index 0000000..5cf1d4c
--- /dev/null
+++ b/PlexBar/Services/PlexAPIClient+MediaProvider.swift
@@ -0,0 +1,123 @@
+import Foundation
+
+extension PlexAPIClient {
+ func fetchLibraryProviderEndpoints(
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> PlexLibraryProviderEndpoints {
+ guard let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: "/media/providers"
+ ) else {
+ throw PlexAPIError.invalidServerURL
+ }
+
+ let request = PlexRequestBuilder(clientContext: configuration.clientContext).request(
+ url: endpoint,
+ accept: "application/json",
+ token: configuration.token
+ )
+ let responseData = try await data(for: request)
+
+ do {
+ let envelope = try JSONDecoder().decode(PlexMediaProvidersEnvelope.self, from: responseData)
+ return try envelope.mediaContainer.libraryProviderEndpoints()
+ } catch let error as PlexAPIError {
+ throw error
+ } catch {
+ throw PlexAPIError.decodingFailed(error)
+ }
+ }
+
+ func setPersonalRating(
+ _ rating: Double?,
+ ratingKey: String,
+ endpoints: PlexLibraryProviderEndpoints,
+ using configuration: PlexConnectionConfiguration
+ ) async throws {
+ let encodedRating = rating ?? 0
+ guard encodedRating.isFinite,
+ (rating == nil || (1...10).contains(encodedRating)) else {
+ throw PlexAPIError.invalidPersonalRating
+ }
+ guard let ratePath = endpoints.ratePath else {
+ throw PlexAPIError.missingLibraryRateFeature
+ }
+ guard let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: ratePath
+ ), var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else {
+ throw PlexAPIError.invalidServerURL
+ }
+ components.queryItems = (components.queryItems ?? []) + [
+ URLQueryItem(name: "identifier", value: endpoints.providerIdentifier),
+ URLQueryItem(name: "key", value: ratingKey),
+ URLQueryItem(name: "rating", value: String(encodedRating)),
+ ]
+
+ guard let url = components.url else {
+ throw PlexAPIError.invalidServerURL
+ }
+ let request = PlexRequestBuilder(clientContext: configuration.clientContext).request(
+ url: url,
+ method: "PUT",
+ accept: "application/json",
+ token: configuration.token
+ )
+ _ = try await responseData(for: request)
+ }
+
+ func refreshMediaMetadata(
+ ratingKey: String,
+ endpoints: PlexLibraryProviderEndpoints,
+ using configuration: PlexConnectionConfiguration
+ ) async throws {
+ guard endpoints.canManage,
+ let metadataPath = endpoints.metadataPath else {
+ throw PlexAPIError.libraryManagementUnavailable
+ }
+ guard let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: metadataPath,
+ appendingPathComponents: [ratingKey, "refresh"]
+ ) else {
+ throw PlexAPIError.invalidServerURL
+ }
+ let request = PlexRequestBuilder(clientContext: configuration.clientContext).request(
+ url: endpoint,
+ method: "PUT",
+ accept: "application/json",
+ token: configuration.token
+ )
+ _ = try await responseData(for: request)
+ }
+
+ func removeFromContinueWatching(
+ ratingKey: String,
+ endpoints: PlexLibraryProviderEndpoints,
+ using configuration: PlexConnectionConfiguration
+ ) async throws {
+ guard let actionPath = endpoints.removeFromContinueWatchingPath else {
+ throw PlexAPIError.missingRemoveFromContinueWatchingAction
+ }
+ guard let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: actionPath
+ ), var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else {
+ throw PlexAPIError.invalidServerURL
+ }
+ components.queryItems = (components.queryItems ?? []) + [
+ URLQueryItem(name: "ratingKey", value: ratingKey),
+ ]
+
+ guard let url = components.url else {
+ throw PlexAPIError.invalidServerURL
+ }
+ let request = PlexRequestBuilder(clientContext: configuration.clientContext).request(
+ url: url,
+ method: "PUT",
+ accept: "application/json",
+ token: configuration.token
+ )
+ _ = try await responseData(for: request)
+ }
+}
diff --git a/PlexBar/Services/PlexAPIClient+People.swift b/PlexBar/Services/PlexAPIClient+People.swift
new file mode 100644
index 0000000..db4bad8
--- /dev/null
+++ b/PlexBar/Services/PlexAPIClient+People.swift
@@ -0,0 +1,104 @@
+import PlexModels
+import Foundation
+
+extension PlexAPIClient {
+ func fetchEpisodeSeriesCast(
+ for item: PlexMediaItem,
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> [PlexTag] {
+ guard let seriesRatingKey = item.episodeSeriesCastRatingKey else {
+ return []
+ }
+
+ let series = try await fetchMediaMetadata(
+ ratingKey: seriesRatingKey,
+ using: configuration
+ )
+ guard series.ratingKey == seriesRatingKey,
+ series.type?.caseInsensitiveCompare("show") == .orderedSame else {
+ throw PlexAPIError.invalidResponse
+ }
+ return series.roles
+ }
+
+ func fetchPerson(
+ identifier: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> PlexTag {
+ let endpoint = try personEndpoint(
+ identifier: identifier,
+ suffix: nil,
+ configuration: configuration
+ )
+ let responseData = try await data(
+ for: peopleRequest(url: endpoint, configuration: configuration)
+ )
+
+ do {
+ let decoded = try JSONDecoder().decode(PlexPeopleEnvelope.self, from: responseData)
+ guard let person = decoded.mediaContainer.people.first else {
+ throw PlexAPIError.invalidResponse
+ }
+ return person
+ } catch let error as PlexAPIError {
+ throw error
+ } catch {
+ throw PlexAPIError.decodingFailed(error)
+ }
+ }
+
+ func fetchPersonMedia(
+ identifier: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> [PlexMediaItem] {
+ let endpoint = try personEndpoint(
+ identifier: identifier,
+ suffix: "media",
+ configuration: configuration
+ )
+ let responseData = try await data(
+ for: peopleRequest(url: endpoint, configuration: configuration)
+ )
+
+ do {
+ return try JSONDecoder()
+ .decode(PlexMediaEnvelope.self, from: responseData)
+ .mediaContainer
+ .metadata
+ } catch {
+ throw PlexAPIError.decodingFailed(error)
+ }
+ }
+}
+
+private extension PlexAPIClient {
+ func peopleRequest(
+ url: URL,
+ configuration: PlexConnectionConfiguration
+ ) -> URLRequest {
+ PlexRequestBuilder(clientContext: configuration.clientContext).request(
+ url: url,
+ accept: "application/json",
+ token: configuration.token
+ )
+ }
+
+ func personEndpoint(
+ identifier: String,
+ suffix: String?,
+ configuration: PlexConnectionConfiguration
+ ) throws -> URL {
+ var components = [identifier]
+ if let suffix {
+ components.append(suffix)
+ }
+ guard let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: "/library/people",
+ appendingPathComponents: components
+ ) else {
+ throw PlexAPIError.invalidServerURL
+ }
+ return endpoint
+ }
+}
diff --git a/PlexBar/Services/PlexAPIClient+PlayQueue.swift b/PlexBar/Services/PlexAPIClient+PlayQueue.swift
new file mode 100644
index 0000000..e1b84d0
--- /dev/null
+++ b/PlexBar/Services/PlexAPIClient+PlayQueue.swift
@@ -0,0 +1,306 @@
+import PlexModels
+import Foundation
+
+extension PlexAPIClient {
+ func createCinemaPlayQueue(
+ for item: PlexMediaItem,
+ extrasPrefixCount: Int,
+ endpointPath: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> PlexPlaybackQueue {
+ guard let serverIdentifier = configuration.serverIdentifier else {
+ throw PlexAPIError.missingServerIdentity
+ }
+ let queueRequest = try PlexCinemaPlayQueueRequest(
+ item: item,
+ extrasPrefixCount: extrasPrefixCount,
+ serverIdentifier: serverIdentifier
+ )
+ guard let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: endpointPath
+ ), var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else {
+ throw PlexAPIError.invalidServerURL
+ }
+
+ components.queryItems = (components.queryItems ?? []) + queueRequest.queryItems
+
+ guard let url = components.url else {
+ throw PlexAPIError.invalidServerURL
+ }
+ let request = PlexRequestBuilder(clientContext: configuration.clientContext).request(
+ url: url,
+ method: "POST",
+ accept: "application/json",
+ token: configuration.token
+ )
+ let page = try await playQueuePage(for: request)
+ guard page.selectedItemID?.nilIfBlank != nil else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+ return try PlexPlaybackQueue(
+ page: page,
+ selectedRatingKey: item.ratingKey,
+ purpose: .cinemaPreplay(primaryRatingKey: item.ratingKey)
+ )
+ }
+
+ func createContinuousPlayQueue(
+ for item: PlexMediaItem,
+ endpointPath: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> PlexPlaybackQueue {
+ guard let serverIdentifier = configuration.serverIdentifier else {
+ throw PlexAPIError.missingServerIdentity
+ }
+ let queueRequest = try PlexContinuousPlayQueueRequest(
+ item: item,
+ serverIdentifier: serverIdentifier
+ )
+ guard let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: endpointPath
+ ), var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else {
+ throw PlexAPIError.invalidServerURL
+ }
+
+ components.queryItems = (components.queryItems ?? []) + queueRequest.queryItems
+
+ guard let url = components.url else {
+ throw PlexAPIError.invalidServerURL
+ }
+ let request = PlexRequestBuilder(clientContext: configuration.clientContext).request(
+ url: url,
+ method: "POST",
+ accept: "application/json",
+ token: configuration.token
+ )
+ let page = try await playQueuePage(for: request)
+ return try PlexPlaybackQueue(page: page, selectedRatingKey: item.ratingKey)
+ }
+
+ func fetchPlayQueuePage(
+ queueID: Int,
+ endpointPath: String,
+ centeredOn playQueueItemID: String,
+ window: Int = 50,
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> PlexPlayQueuePage {
+ guard let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: endpointPath,
+ appendingPathComponent: String(queueID)
+ ), var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else {
+ throw PlexAPIError.invalidServerURL
+ }
+ components.queryItems = (components.queryItems ?? []) + [
+ URLQueryItem(name: "center", value: playQueueItemID),
+ URLQueryItem(name: "window", value: String(max(window, 1))),
+ URLQueryItem(name: "includeBefore", value: "1"),
+ URLQueryItem(name: "includeAfter", value: "1"),
+ ]
+
+ guard let url = components.url else {
+ throw PlexAPIError.invalidServerURL
+ }
+ let request = PlexRequestBuilder(clientContext: configuration.clientContext).request(
+ url: url,
+ accept: "application/json",
+ token: configuration.token
+ )
+ return try await playQueuePage(for: request)
+ }
+
+ func addToPlayQueue(
+ _ item: PlexMediaItem,
+ queueID: Int,
+ insertion: PlexPlayQueueInsertion,
+ endpointPath: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> PlexPlayQueuePage {
+ guard let serverIdentifier = configuration.serverIdentifier else {
+ throw PlexAPIError.missingServerIdentity
+ }
+ guard let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: endpointPath,
+ appendingPathComponent: String(queueID)
+ ), var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else {
+ throw PlexAPIError.invalidServerURL
+ }
+ let sourceURI = try PlexMediaSourceURI.item(
+ item,
+ serverIdentifier: serverIdentifier
+ )
+ components.queryItems = (components.queryItems ?? []) + [
+ URLQueryItem(name: "uri", value: sourceURI),
+ URLQueryItem(name: "next", value: insertion.queryValue),
+ ]
+
+ guard let url = components.url else {
+ throw PlexAPIError.invalidServerURL
+ }
+ let request = PlexRequestBuilder(clientContext: configuration.clientContext).request(
+ url: url,
+ method: "PUT",
+ accept: "application/json",
+ token: configuration.token
+ )
+ return try await playQueuePage(for: request)
+ }
+
+ func setPlayQueueShuffled(
+ _ shuffled: Bool,
+ queueID: Int,
+ endpointPath: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> PlexPlayQueuePage {
+ let mutation = try PlexPlayQueueMutationRequest(
+ queueID: queueID,
+ mutation: .shuffled(shuffled)
+ )
+ guard let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: endpointPath,
+ appendingPathComponents: mutation.endpointPathComponents
+ ) else {
+ throw PlexAPIError.invalidServerURL
+ }
+ let request = PlexRequestBuilder(clientContext: configuration.clientContext).request(
+ url: endpoint,
+ method: "PUT",
+ accept: "application/json",
+ token: configuration.token
+ )
+ return try await playQueuePage(for: request)
+ }
+
+ func removePlayQueueItem(
+ queueID: Int,
+ playQueueItemID: String,
+ endpointPath: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> PlexPlayQueuePage {
+ let mutation = try PlexPlayQueueItemMutationRequest(
+ queueID: queueID,
+ mutation: .remove(playQueueItemID: playQueueItemID)
+ )
+ guard let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: endpointPath,
+ appendingPathComponents: mutation.endpointPathComponents
+ ) else {
+ throw PlexAPIError.invalidServerURL
+ }
+ let request = PlexRequestBuilder(clientContext: configuration.clientContext).request(
+ url: endpoint,
+ method: mutation.method,
+ accept: "application/json",
+ token: configuration.token
+ )
+ return try await playQueuePage(for: request)
+ }
+
+ func movePlayQueueItem(
+ queueID: Int,
+ move: PlexPlayQueueItemMove,
+ endpointPath: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> PlexPlayQueuePage {
+ let mutation = try PlexPlayQueueItemMutationRequest(
+ queueID: queueID,
+ mutation: .move(move)
+ )
+ guard let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: endpointPath,
+ appendingPathComponents: mutation.endpointPathComponents
+ ),
+ var components = URLComponents(
+ url: endpoint,
+ resolvingAgainstBaseURL: false
+ ) else {
+ throw PlexAPIError.invalidServerURL
+ }
+ components.queryItems = (components.queryItems ?? []) + mutation.queryItems
+ guard let url = components.url else {
+ throw PlexAPIError.invalidServerURL
+ }
+ let request = PlexRequestBuilder(clientContext: configuration.clientContext).request(
+ url: url,
+ method: mutation.method,
+ accept: "application/json",
+ token: configuration.token
+ )
+ return try await playQueuePage(for: request)
+ }
+
+ func resetPlayQueue(
+ queueID: Int,
+ endpointPath: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> PlexPlayQueuePage {
+ let mutation = try PlexPlayQueueMutationRequest(
+ queueID: queueID,
+ mutation: .reset
+ )
+ guard let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: endpointPath,
+ appendingPathComponents: mutation.endpointPathComponents
+ ) else {
+ throw PlexAPIError.invalidServerURL
+ }
+ let request = PlexRequestBuilder(clientContext: configuration.clientContext).request(
+ url: endpoint,
+ method: "PUT",
+ accept: "application/json",
+ token: configuration.token
+ )
+ return try await playQueuePage(for: request)
+ }
+
+ func setWatched(
+ _ watched: Bool,
+ ratingKey: String,
+ endpoints: PlexLibraryProviderEndpoints,
+ using configuration: PlexConnectionConfiguration
+ ) async throws {
+ let parameters = try PlexWatchedStateRequestParameters(
+ watched: watched,
+ ratingKey: ratingKey,
+ endpoints: endpoints
+ )
+ guard let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: parameters.endpointPath
+ ), var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else {
+ throw PlexAPIError.invalidServerURL
+ }
+ components.queryItems = (components.queryItems ?? []) + parameters.queryItems
+
+ guard let url = components.url else {
+ throw PlexAPIError.invalidServerURL
+ }
+ let request = PlexRequestBuilder(clientContext: configuration.clientContext).request(
+ url: url,
+ method: "PUT",
+ accept: "application/json",
+ token: configuration.token
+ )
+ _ = try await responseData(for: request)
+ }
+
+ private func playQueuePage(for request: URLRequest) async throws -> PlexPlayQueuePage {
+ let data = try await data(for: request)
+ do {
+ let envelope = try JSONDecoder().decode(PlexPlayQueueEnvelope.self, from: data)
+ return try envelope.mediaContainer.page()
+ } catch let error as PlexAPIError {
+ throw error
+ } catch {
+ throw PlexAPIError.decodingFailed(error)
+ }
+ }
+
+}
diff --git a/Sources/PlexBar/Services/PlexAPIClient.swift b/PlexBar/Services/PlexAPIClient.swift
similarity index 79%
rename from Sources/PlexBar/Services/PlexAPIClient.swift
rename to PlexBar/Services/PlexAPIClient.swift
index 326b2f5..846ff45 100644
--- a/Sources/PlexBar/Services/PlexAPIClient.swift
+++ b/PlexBar/Services/PlexAPIClient.swift
@@ -1,3 +1,5 @@
+import PlexModels
+import CryptoKit
import Foundation
struct PlexAPIClient {
@@ -11,7 +13,10 @@ struct PlexAPIClient {
using configuration: PlexConnectionConfiguration,
timeoutInterval: TimeInterval? = nil
) async throws -> PlexServerIdentity {
- guard let endpoint = PlexURLBuilder.endpointURL(serverURL: configuration.serverURL, path: "/identity") else {
+ guard
+ let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL, path: "/identity")
+ else {
throw PlexAPIError.invalidServerURL
}
@@ -35,7 +40,9 @@ struct PlexAPIClient {
}
}
- func fetchLibraries(using configuration: PlexConnectionConfiguration) async throws -> [PlexLibrary] {
+ func fetchLibraries(using configuration: PlexConnectionConfiguration) async throws
+ -> [PlexLibrary]
+ {
let sections = try await fetchLibrarySections(using: configuration)
return try await withThrowingTaskGroup(of: PlexLibrary.self) { group in
@@ -61,7 +68,10 @@ struct PlexAPIClient {
}
func fetchSessions(using configuration: PlexConnectionConfiguration) async throws -> [PlexSession] {
- guard let endpoint = PlexURLBuilder.endpointURL(serverURL: configuration.serverURL, path: "/status/sessions") else {
+ guard
+ let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL, path: "/status/sessions")
+ else {
throw PlexAPIError.invalidServerURL
}
@@ -89,56 +99,16 @@ struct PlexAPIClient {
}
}
- func fetchSession(
- using configuration: PlexConnectionConfiguration,
- sessionKey: String
- ) async throws -> PlexSession? {
- guard let endpoint = PlexURLBuilder.endpointURL(serverURL: configuration.serverURL, path: "/status/sessions"),
- var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else {
- throw PlexAPIError.invalidServerURL
- }
-
- components.queryItems = [
- URLQueryItem(name: "sessionKey", value: sessionKey)
- ]
-
- guard let sessionURL = components.url else {
- throw PlexAPIError.invalidServerURL
- }
-
- let request = PlexRequestBuilder(clientContext: configuration.clientContext).request(
- url: sessionURL,
- accept: "application/json",
- token: configuration.token
- )
-
- let (data, response) = try await session.data(for: request)
-
- guard let httpResponse = response as? HTTPURLResponse else {
- throw PlexAPIError.invalidResponse
- }
-
- guard (200..<300).contains(httpResponse.statusCode) else {
- throw PlexAPIError.badStatusCode(httpResponse.statusCode)
- }
-
- do {
- let decodedResponse = try JSONDecoder().decode(PlexSessionsEnvelope.self, from: data)
- return decodedResponse.mediaContainer.metadata?.first(where: {
- $0.canonicalSessionKey == sessionKey
- })
- } catch {
- throw PlexAPIError.decodingFailed(error)
- }
- }
-
func terminateSession(
using configuration: PlexConnectionConfiguration,
sessionID: String,
reason: String? = nil
) async throws {
- guard let endpoint = PlexURLBuilder.endpointURL(serverURL: configuration.serverURL, path: "/status/sessions/terminate"),
- var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else {
+ guard
+ let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL, path: "/status/sessions/terminate"),
+ var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)
+ else {
throw PlexAPIError.invalidServerURL
}
@@ -167,11 +137,13 @@ struct PlexAPIClient {
streamID: Int,
subsample: Int
) async throws -> [Double] {
- guard let endpoint = PlexURLBuilder.endpointURL(
- serverURL: configuration.serverURL,
- path: "/library/streams/\(streamID)/levels"
- ),
- var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else {
+ guard
+ let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: "/library/streams/\(streamID)/levels"
+ ),
+ var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)
+ else {
throw PlexAPIError.invalidServerURL
}
@@ -202,10 +174,14 @@ struct PlexAPIClient {
func fetchHistory(
using configuration: PlexConnectionConfiguration,
since: Date,
+ metadataItemID: Int? = nil,
pageSize: Int = 200
) async throws -> [PlexHistoryItem] {
- guard let endpoint = PlexURLBuilder.endpointURL(serverURL: configuration.serverURL, path: "/status/sessions/history/all"),
- var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else {
+ guard
+ let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL, path: "/status/sessions/history/all"),
+ var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)
+ else {
throw PlexAPIError.invalidServerURL
}
@@ -215,10 +191,18 @@ struct PlexAPIClient {
var totalSize: Int?
while true {
- components.queryItems = [
+ var queryItems = [
URLQueryItem(name: "sort", value: "viewedAt:desc"),
- URLQueryItem(name: "viewedAt>", value: cutoffTimestamp)
+ URLQueryItem(name: "viewedAt>", value: cutoffTimestamp),
]
+ if let metadataItemID {
+ queryItems.append(
+ URLQueryItem(
+ name: "metadataItemID",
+ value: String(metadataItemID)
+ ))
+ }
+ components.queryItems = queryItems
guard let historyURL = components.url else {
throw PlexAPIError.invalidServerURL
@@ -248,8 +232,10 @@ struct PlexAPIClient {
allItems.append(contentsOf: pageItems)
if totalSize == nil,
- let totalSizeHeader = httpResponse.value(forHTTPHeaderField: "X-Plex-Container-Total-Size"),
- let parsedTotalSize = Int(totalSizeHeader) {
+ let totalSizeHeader = httpResponse.value(
+ forHTTPHeaderField: "X-Plex-Container-Total-Size"),
+ let parsedTotalSize = Int(totalSizeHeader)
+ {
totalSize = parsedTotalSize
}
@@ -270,8 +256,13 @@ struct PlexAPIClient {
return allItems
}
- func fetchAccounts(using configuration: PlexConnectionConfiguration) async throws -> [PlexAccount] {
- guard let endpoint = PlexURLBuilder.endpointURL(serverURL: configuration.serverURL, path: "/statistics/media") else {
+ func fetchHistoryIdentityDirectory(
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> PlexHistoryIdentityDirectory {
+ guard
+ let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL, path: "/statistics/media")
+ else {
throw PlexAPIError.invalidServerURL
}
@@ -293,7 +284,10 @@ struct PlexAPIClient {
do {
let decodedResponse = try JSONDecoder().decode(PlexStatisticsEnvelope.self, from: data)
- return decodedResponse.mediaContainer.accounts ?? []
+ return PlexHistoryIdentityDirectory(
+ accounts: decodedResponse.mediaContainer.accounts ?? [],
+ devices: decodedResponse.mediaContainer.devices ?? []
+ )
} catch {
throw PlexAPIError.decodingFailed(error)
}
@@ -311,10 +305,12 @@ struct PlexAPIClient {
var items: [PlexMetadataItem] = []
for chunk in ids.chunked(into: chunkSize) {
- guard let endpoint = PlexURLBuilder.endpointURL(
- serverURL: configuration.serverURL,
- path: "/library/metadata/\(chunk.joined(separator: ","))"
- ) else {
+ guard
+ let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: "/library/metadata/\(chunk.joined(separator: ","))"
+ )
+ else {
throw PlexAPIError.invalidServerURL
}
@@ -378,7 +374,8 @@ struct PlexAPIClient {
}
var seenItemIDs = Set()
- return allItems
+ return
+ allItems
.filter(\.hasArtwork)
.sorted { lhs, rhs in
if lhs.addedAt != rhs.addedAt {
@@ -407,7 +404,8 @@ struct PlexAPIClient {
ids: Array(requestedEpisodeIDs).sorted()
)
- let resolvedIdentities = Dictionary(uniqueKeysWithValues: metadataItems.compactMap(\.historySeriesResolution))
+ let resolvedIdentities = Dictionary(
+ uniqueKeysWithValues: metadataItems.compactMap(\.historySeriesResolution))
let unresolvedEpisodeIDs = requestedEpisodeIDs.subtracting(resolvedIdentities.keys)
guard unresolvedEpisodeIDs.isEmpty else {
@@ -417,8 +415,13 @@ struct PlexAPIClient {
return resolvedIdentities
}
- private func fetchLibrarySections(using configuration: PlexConnectionConfiguration) async throws -> [PlexLibrarySection] {
- guard let endpoint = PlexURLBuilder.endpointURL(serverURL: configuration.serverURL, path: "/library/sections/all") else {
+ private func fetchLibrarySections(using configuration: PlexConnectionConfiguration) async throws
+ -> [PlexLibrarySection]
+ {
+ guard
+ let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL, path: "/library/sections/all")
+ else {
throw PlexAPIError.invalidServerURL
}
@@ -442,11 +445,13 @@ struct PlexAPIClient {
for section: PlexLibrarySection,
using configuration: PlexConnectionConfiguration
) async throws -> PlexLibrary {
- guard let endpoint = PlexURLBuilder.endpointURL(
- serverURL: configuration.serverURL,
- path: "/library/sections/\(section.key)/all"
- ),
- var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else {
+ guard
+ let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: "/library/sections/\(section.key)/all"
+ ),
+ var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)
+ else {
throw PlexAPIError.invalidServerURL
}
@@ -463,22 +468,23 @@ struct PlexAPIClient {
using: configuration
)
- let secondarySummaryTask: Task<(count: Int, label: String)?, Never>? = if let secondarySummary = PlexLibraryType(rawValue: section.type).preferredSecondarySummary {
- Task {
- do {
- let count = try await fetchLibraryCount(
- sectionID: section.key,
- type: secondarySummary.queryType,
- using: configuration
- )
- return (count, secondarySummary.label)
- } catch {
- return nil
+ let secondarySummaryTask: Task<(count: Int, label: String)?, Never>? =
+ if let secondarySummary = PlexLibraryType(rawValue: section.type).preferredSecondarySummary {
+ Task {
+ do {
+ let count = try await fetchLibraryCount(
+ sectionID: section.key,
+ type: secondarySummary.queryType,
+ using: configuration
+ )
+ return (count, secondarySummary.label)
+ } catch {
+ return nil
+ }
}
+ } else {
+ nil
}
- } else {
- nil
- }
let primarySummary = try await primarySummaryTask
let secondarySummary = await secondarySummaryTask?.value
@@ -508,7 +514,9 @@ struct PlexAPIClient {
do {
let decodedResponse = try JSONDecoder().decode(PlexLibraryItemsEnvelope.self, from: data)
let recentItem = decodedResponse.mediaContainer.metadata?.first
- let totalSize = totalSize(from: response) ?? decodedResponse.mediaContainer.totalSize ?? decodedResponse.mediaContainer.size ?? 0
+ let totalSize =
+ totalSize(from: response) ?? decodedResponse.mediaContainer.totalSize ?? decodedResponse
+ .mediaContainer.size ?? 0
return (totalSize, recentItem)
} catch {
throw PlexAPIError.decodingFailed(error)
@@ -520,11 +528,13 @@ struct PlexAPIClient {
type: Int,
using configuration: PlexConnectionConfiguration
) async throws -> Int {
- guard let endpoint = PlexURLBuilder.endpointURL(
- serverURL: configuration.serverURL,
- path: "/library/sections/\(sectionID)/all"
- ),
- var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else {
+ guard
+ let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: "/library/sections/\(sectionID)/all"
+ ),
+ var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)
+ else {
throw PlexAPIError.invalidServerURL
}
@@ -553,11 +563,13 @@ struct PlexAPIClient {
limit: Int,
using configuration: PlexConnectionConfiguration
) async throws -> [PlexServerPreviewItem] {
- guard let endpoint = PlexURLBuilder.endpointURL(
- serverURL: configuration.serverURL,
- path: "/library/sections/\(section.key)/all"
- ),
- var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else {
+ guard
+ let endpoint = PlexURLBuilder.endpointURL(
+ serverURL: configuration.serverURL,
+ path: "/library/sections/\(section.key)/all"
+ ),
+ var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)
+ else {
throw PlexAPIError.invalidServerURL
}
@@ -589,12 +601,12 @@ struct PlexAPIClient {
}
}
- private func data(for request: URLRequest) async throws -> Data {
+ func data(for request: URLRequest) async throws -> Data {
let (data, _) = try await responseData(for: request)
return data
}
- private func responseData(for request: URLRequest) async throws -> (Data, HTTPURLResponse) {
+ func responseData(for request: URLRequest) async throws -> (Data, HTTPURLResponse) {
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
@@ -608,7 +620,7 @@ struct PlexAPIClient {
return (data, httpResponse)
}
- private func totalSize(from response: HTTPURLResponse) -> Int? {
+ func totalSize(from response: HTTPURLResponse) -> Int? {
guard let value = response.value(forHTTPHeaderField: "X-Plex-Container-Total-Size") else {
return nil
}
@@ -621,31 +633,45 @@ struct PlexConnectionConfiguration {
let serverURL: URL
let token: String
let clientContext: PlexClientContext
-}
+ let serverIdentifier: String?
-enum PlexAPIError: LocalizedError {
- case invalidServerURL
- case missingToken
- case invalidResponse
- case badStatusCode(Int)
- case decodingFailed(Error)
- case missingHistorySeriesIdentity([String])
-
- var errorDescription: String? {
- switch self {
- case .invalidServerURL:
- return "Enter a valid Plex server URL, for example http://192.168.1.10:32400."
- case .missingToken:
- return "Add a Plex token before refreshing sessions."
- case .invalidResponse:
- return "Plex returned a response that PlexBar could not read."
- case .badStatusCode(let statusCode):
- return "Plex returned HTTP \(statusCode). Check the server URL and token."
- case .decodingFailed:
- return "Plex returned data in an unexpected format."
- case .missingHistorySeriesIdentity:
- return "Plex did not return enough metadata to build watch history charts."
- }
+ init(
+ serverURL: URL,
+ token: String,
+ clientContext: PlexClientContext,
+ serverIdentifier: String? = nil
+ ) {
+ self.serverURL = serverURL
+ self.token = token
+ self.clientContext = clientContext
+ self.serverIdentifier = serverIdentifier
+ }
+
+ var authenticationCacheScope: String {
+ Self.authenticationCacheScope(for: token)
+ }
+
+ var accountCacheScope: String {
+ Self.accountCacheScope(
+ serverIdentifier: serverIdentifier,
+ token: token
+ )
+ }
+
+ static func authenticationCacheScope(for token: String) -> String {
+ SHA256.hash(data: Data(token.utf8))
+ .map { String(format: "%02x", $0) }
+ .joined()
+ }
+
+ static func accountCacheScope(
+ serverIdentifier: String?,
+ token: String
+ ) -> String {
+ [
+ serverIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "",
+ authenticationCacheScope(for: token),
+ ].joined(separator: "|")
}
}
@@ -714,6 +740,7 @@ private struct PlexLibrarySection: Decodable {
let hidden: Bool?
let content: Bool?
let directory: Bool?
+ let allowSync: Bool?
enum CodingKeys: String, CodingKey {
case key
@@ -728,6 +755,7 @@ private struct PlexLibrarySection: Decodable {
case hidden
case content
case directory
+ case allowSync
}
init(from decoder: Decoder) throws {
@@ -744,6 +772,7 @@ private struct PlexLibrarySection: Decodable {
hidden = try container.decodeFlexibleBoolIfPresent(forKey: .hidden)
content = try container.decodeFlexibleBoolIfPresent(forKey: .content)
directory = try container.decodeFlexibleBoolIfPresent(forKey: .directory)
+ allowSync = try container.decodeFlexibleBoolIfPresent(forKey: .allowSync)
}
var isBrowsableLibrary: Bool {
@@ -770,7 +799,8 @@ private struct PlexLibrarySection: Decodable {
scannedAt: scannedAt.map { Date(timeIntervalSince1970: TimeInterval($0)) },
contentChangedAt: contentChangedAt.map { Date(timeIntervalSince1970: TimeInterval($0)) },
latestAddedAt: recentItem?.addedAt.map { Date(timeIntervalSince1970: TimeInterval($0)) },
- latestItemTitle: recentItem?.title?.nilIfBlank
+ latestItemTitle: recentItem?.title?.nilIfBlank,
+ allowSync: allowSync
)
}
}
@@ -804,11 +834,13 @@ private struct PlexLibraryRecentItem: Decodable {
func serverPreviewItem(sectionID: String) -> PlexServerPreviewItem? {
guard let title = title?.nilIfBlank,
- let addedAt else {
+ let addedAt
+ else {
return nil
}
- let identifier = ratingKey?.nilIfBlank
+ let identifier =
+ ratingKey?.nilIfBlank
?? "\(sectionID)|\(title)|\(addedAt)"
return PlexServerPreviewItem(
@@ -821,8 +853,8 @@ private struct PlexLibraryRecentItem: Decodable {
}
}
-private extension KeyedDecodingContainer {
- func decodeFlexibleBoolIfPresent(forKey key: Key) throws -> Bool? {
+extension KeyedDecodingContainer {
+ fileprivate func decodeFlexibleBoolIfPresent(forKey key: Key) throws -> Bool? {
if contains(key) == false {
return nil
}
@@ -880,9 +912,11 @@ private struct PlexStatisticsEnvelope: Decodable {
private struct PlexStatisticsContainer: Decodable {
let accounts: [PlexAccount]?
+ let devices: [PlexHistoryDevice]?
enum CodingKeys: String, CodingKey {
case accounts = "Account"
+ case devices = "Device"
}
}
@@ -901,8 +935,9 @@ struct PlexMetadataItem: Decodable, Equatable {
var historySeriesResolution: (String, PlexHistorySeriesIdentity)? {
guard let episodeID = ratingKey.nilIfBlank,
- let seriesID = grandparentRatingKey?.nilIfBlank,
- let seriesTitle = grandparentTitle?.nilIfBlank else {
+ let seriesID = grandparentRatingKey?.nilIfBlank,
+ let seriesTitle = grandparentTitle?.nilIfBlank
+ else {
return nil
}
@@ -933,8 +968,8 @@ private struct PlexMetadataContainer: Decodable {
}
}
-private extension Array {
- func chunked(into size: Int) -> [[Element]] {
+extension Array {
+ fileprivate func chunked(into size: Int) -> [[Element]] {
guard size > 0 else {
return [self]
}
diff --git a/PlexBar/Services/PlexAuthClient.swift b/PlexBar/Services/PlexAuthClient.swift
new file mode 100644
index 0000000..8712bd5
--- /dev/null
+++ b/PlexBar/Services/PlexAuthClient.swift
@@ -0,0 +1,296 @@
+import PlexModels
+import Foundation
+
+protocol PlexAccountJWTClient: Sendable {
+ func registerJWK(
+ _ jwk: PlexJSONWebKey,
+ legacyToken: String,
+ clientContext: PlexClientContext
+ ) async throws
+ func fetchJWTNonce(clientContext: PlexClientContext) async throws -> String
+ func exchangeDeviceJWT(_ deviceJWT: String, clientContext: PlexClientContext) async throws -> String
+}
+
+struct PlexAuthClient: PlexAccountJWTClient, Sendable {
+ private let session: URLSession
+
+ init(session: URLSession = .shared) {
+ self.session = session
+ }
+
+ func fetchAuthenticatedUser(
+ userToken: String,
+ clientContext: PlexClientContext
+ ) async throws -> PlexAuthenticatedUser {
+ let request = PlexRequestBuilder(clientContext: clientContext).request(
+ url: PlexRemoteService.apiURL(path: "/api/v2/user"),
+ accept: "application/json",
+ token: userToken
+ )
+
+ let (data, response) = try await session.data(for: request)
+ try validate(response: response)
+ return try JSONDecoder().decode(PlexAuthenticatedUser.self, from: data)
+ }
+
+ func createPin(
+ jwk: PlexJSONWebKey,
+ strong: Bool = true,
+ clientContext: PlexClientContext
+ ) async throws -> PlexPin {
+ var request = PlexRequestBuilder(clientContext: clientContext).request(
+ url: PlexRemoteService.clientsURL(path: "/api/v2/pins"),
+ method: "POST",
+ accept: "application/json"
+ )
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.httpBody = try JSONEncoder().encode(PlexPinRequest(jwk: jwk, strong: strong))
+
+ let (data, response) = try await session.data(for: request)
+ try validate(response: response)
+ return try JSONDecoder().decode(PlexPin.self, from: data)
+ }
+
+ func fetchPin(
+ id: String,
+ deviceJWT: String,
+ clientContext: PlexClientContext
+ ) async throws -> PlexPin {
+ let request = PlexRequestBuilder(clientContext: clientContext).request(
+ url: PlexRemoteService.clientsURL(
+ path: "/api/v2/pins/\(id)",
+ queryItems: [URLQueryItem(name: "deviceJWT", value: deviceJWT)]
+ ),
+ accept: "application/json"
+ )
+
+ let (data, response) = try await session.data(for: request)
+ try validate(response: response)
+ return try JSONDecoder().decode(PlexPin.self, from: data)
+ }
+
+ func registerJWK(
+ _ jwk: PlexJSONWebKey,
+ legacyToken: String,
+ clientContext: PlexClientContext
+ ) async throws {
+ var request = PlexRequestBuilder(clientContext: clientContext).request(
+ url: PlexRemoteService.clientsURL(path: "/api/v2/auth/jwk"),
+ method: "POST",
+ accept: "application/json",
+ token: legacyToken
+ )
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.httpBody = try JSONEncoder().encode(PlexJWKRegistrationRequest(jwk: jwk))
+ let (_, response) = try await session.data(for: request)
+ try validate(response: response)
+ }
+
+ func fetchJWTNonce(clientContext: PlexClientContext) async throws -> String {
+ let request = PlexRequestBuilder(clientContext: clientContext).request(
+ url: PlexRemoteService.clientsURL(path: "/api/v2/auth/nonce"),
+ accept: "application/json"
+ )
+ let (data, response) = try await session.data(for: request)
+ try validate(response: response)
+ return try JSONDecoder().decode(PlexJWTNonceResponse.self, from: data).nonce
+ }
+
+ func exchangeDeviceJWT(
+ _ deviceJWT: String,
+ clientContext: PlexClientContext
+ ) async throws -> String {
+ var request = PlexRequestBuilder(clientContext: clientContext).request(
+ url: PlexRemoteService.clientsURL(path: "/api/v2/auth/token"),
+ method: "POST",
+ accept: "application/json"
+ )
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.httpBody = try JSONEncoder().encode(PlexJWTExchangeRequest(jwt: deviceJWT))
+ let (data, response) = try await session.data(for: request)
+ try validate(response: response)
+ return try JSONDecoder().decode(PlexJWTExchangeResponse.self, from: data).authToken
+ }
+
+ func fetchServers(userToken: String, clientContext: PlexClientContext) async throws -> [PlexServerResource] {
+ let requestBuilder = PlexRequestBuilder(clientContext: clientContext)
+ let resourcesRequest = requestBuilder.request(
+ url: PlexRemoteService.clientsURL(
+ path: "/api/v2/resources",
+ queryItems: [
+ URLQueryItem(name: "includeHttps", value: "1"),
+ URLQueryItem(name: "includeRelay", value: "1"),
+ URLQueryItem(name: "includeIPv6", value: "1")
+ ]
+ ),
+ accept: "application/json",
+ token: userToken
+ )
+ let devicesRequest = requestBuilder.request(
+ url: PlexRemoteService.clientsURL(path: "/api/v2/devices"),
+ accept: "application/json",
+ token: userToken
+ )
+
+ let (resourcesData, resourcesResponse) = try await session.data(for: resourcesRequest)
+ try validate(response: resourcesResponse)
+ let (devicesData, devicesResponse) = try await session.data(for: devicesRequest)
+ try validate(response: devicesResponse)
+
+ let decoder = JSONDecoder()
+ let resourceResponses = try decoder.decode([PlexServerResourceResponse].self, from: resourcesData)
+ let deviceResponses = try decoder.decode([PlexServerDeviceResponse].self, from: devicesData)
+ let tokensByServerID = deviceResponses.reduce(into: [String: String]()) { result, device in
+ guard let credential = device.serverCredential else {
+ return
+ }
+ result[credential.serverID] = credential.token
+ }
+
+ return resourceResponses.compactMap { resource in
+ guard let identifier = resource.clientIdentifier?.nilIfBlank,
+ let token = tokensByServerID[identifier] else {
+ return nil
+ }
+ return resource.serverResource(accessToken: token)
+ }
+ }
+
+ private func validate(response: URLResponse) throws {
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw PlexAuthError.invalidResponse
+ }
+
+ guard (200..<300).contains(httpResponse.statusCode) else {
+ throw PlexAuthError.badStatusCode(httpResponse.statusCode)
+ }
+ }
+
+}
+
+extension PlexAuthError {
+ var requiresTokenRefresh: Bool {
+ guard case .badStatusCode(let statusCode) = self else {
+ return false
+ }
+ return statusCode == 401 || statusCode == 498
+ }
+}
+
+private struct PlexServerResourceResponse: Decodable {
+ let name: String?
+ let clientIdentifier: String?
+ let provides: String?
+ let productVersion: String?
+ let connections: [PlexServerConnectionResponse]?
+
+ func serverResource(accessToken: String) -> PlexServerResource? {
+ let provides = provides ?? ""
+ guard provides.split(separator: ",").contains(where: { $0 == "server" }) else {
+ return nil
+ }
+
+ guard let identifier = clientIdentifier?.nilIfBlank,
+ let name = name?.nilIfBlank else {
+ return nil
+ }
+
+ let connections = (connections ?? []).compactMap(\.serverConnection)
+ guard !connections.isEmpty else {
+ return nil
+ }
+
+ return PlexServerResource(
+ id: identifier,
+ name: name,
+ productVersion: productVersion?.nilIfBlank,
+ accessToken: accessToken,
+ connections: connections
+ )
+ }
+}
+
+private struct PlexServerDeviceResponse: Decodable {
+ let clientIdentifier: String?
+ let provides: String?
+ let token: String?
+
+ var serverCredential: (serverID: String, token: String)? {
+ let provides = provides ?? ""
+ guard provides.split(separator: ",").contains(where: { $0 == "server" }),
+ let serverID = clientIdentifier?.nilIfBlank,
+ let token = token?.nilIfBlank else {
+ return nil
+ }
+ return (serverID, token)
+ }
+}
+
+private struct PlexServerConnectionResponse: Decodable {
+ let uri: String?
+ let local: Bool?
+ let relay: Bool?
+
+ var serverConnection: PlexServerConnection? {
+ guard let uri = uri?.nilIfBlank.flatMap(URL.init(string:)) else {
+ return nil
+ }
+
+ return PlexServerConnection(
+ uri: uri,
+ local: local ?? false,
+ relay: relay ?? false
+ )
+ }
+}
+
+struct PlexPin: Decodable {
+ let id: Int
+ let code: String
+ let authToken: String?
+}
+
+private struct PlexPinRequest: Encodable {
+ let jwk: PlexJSONWebKey
+ let strong: Bool
+}
+
+private struct PlexJWKRegistrationRequest: Encodable {
+ let jwk: PlexJSONWebKey
+}
+
+private struct PlexJWTNonceResponse: Decodable {
+ let nonce: String
+}
+
+private struct PlexJWTExchangeRequest: Encodable {
+ let jwt: String
+}
+
+private struct PlexJWTExchangeResponse: Decodable {
+ let authToken: String
+
+ private enum CodingKeys: String, CodingKey {
+ case authToken = "auth_token"
+ }
+}
+
+enum PlexAuthError: LocalizedError {
+ case invalidAuthURL
+ case invalidResponse
+ case badStatusCode(Int)
+ case noServersFound
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidAuthURL:
+ return "PlexBar could not build the Plex sign-in URL."
+ case .invalidResponse:
+ return "Plex.tv returned a response PlexBar could not read."
+ case .badStatusCode(let statusCode):
+ return "Plex.tv returned HTTP \(statusCode)."
+ case .noServersFound:
+ return "No Plex Media Servers were found for this account."
+ }
+ }
+}
diff --git a/Sources/PlexBar/Services/PlexConnectionResolver.swift b/PlexBar/Services/PlexConnectionResolver.swift
similarity index 87%
rename from Sources/PlexBar/Services/PlexConnectionResolver.swift
rename to PlexBar/Services/PlexConnectionResolver.swift
index 2c1eddb..1b442ee 100644
--- a/Sources/PlexBar/Services/PlexConnectionResolver.swift
+++ b/PlexBar/Services/PlexConnectionResolver.swift
@@ -1,3 +1,4 @@
+import PlexModels
import Foundation
actor PlexConnectionResolver {
@@ -14,6 +15,7 @@ actor PlexConnectionResolver {
clientContext: PlexClientContext,
cachedURL: URL?
) async throws -> PlexResolvedConnection {
+ var failureCodes: [URLError.Code] = []
if let cachedURL,
let cachedConnection = server.connections.first(where: { $0.uri == cachedURL }) {
switch try await probe(
@@ -24,8 +26,8 @@ actor PlexConnectionResolver {
) {
case .success(let resolved):
return resolved
- case .unreachable:
- break
+ case .unreachable(let code):
+ failureCodes.append(code)
case .hardFailure(let error):
throw error
}
@@ -42,13 +44,14 @@ actor PlexConnectionResolver {
candidates,
expectedServerID: server.id,
token: server.accessToken,
- clientContext: clientContext
+ clientContext: clientContext,
+ failureCodes: &failureCodes
) {
return resolved
}
}
- throw PlexConnectionResolutionError.noReachableConnection(server.name)
+ throw PlexServerConnectionFailure(serverName: server.name, failureCodes: failureCodes)
}
func validateCachedConnection(
@@ -74,11 +77,9 @@ actor PlexConnectionResolver {
return resolved
case .hardFailure(let error):
throw error
- case .unreachable:
- break
+ case .unreachable(let code):
+ throw PlexServerConnectionFailure(serverName: url.host ?? "server", failureCodes: [code])
}
-
- throw PlexConnectionResolutionError.noReachableConnection(url.host ?? "server")
}
private func rankedConnections(for connections: [PlexServerConnection]) -> [PlexServerConnection] {
@@ -101,7 +102,8 @@ actor PlexConnectionResolver {
_ connections: [PlexServerConnection],
expectedServerID: String,
token: String,
- clientContext: PlexClientContext
+ clientContext: PlexClientContext,
+ failureCodes: inout [URLError.Code]
) async throws -> PlexResolvedConnection? {
for connection in connections {
switch try await probe(
@@ -112,7 +114,8 @@ actor PlexConnectionResolver {
) {
case .success(let resolved):
return resolved
- case .unreachable:
+ case .unreachable(let code):
+ failureCodes.append(code)
continue
case .hardFailure(let error):
throw error
@@ -159,8 +162,8 @@ actor PlexConnectionResolver {
} catch is CancellationError {
throw CancellationError()
} catch {
- if error.isPlexConnectivityFailure {
- return .unreachable
+ if let code = error.plexConnectivityFailureCode {
+ return .unreachable(code)
}
return .hardFailure(error)
@@ -170,18 +173,15 @@ actor PlexConnectionResolver {
private enum ConnectionProbeResult {
case success(PlexResolvedConnection)
- case unreachable
+ case unreachable(URLError.Code)
case hardFailure(Error)
}
enum PlexConnectionResolutionError: LocalizedError {
- case noReachableConnection(String)
case identityMismatch(expected: String, actual: String, url: URL)
var errorDescription: String? {
switch self {
- case .noReachableConnection(let serverName):
- return "PlexBar could not reach any advertised connection for \(serverName)."
case .identityMismatch(let expected, let actual, let url):
return "PlexBar expected server \(expected) at \(url.host ?? url.absoluteString), but PMS reported \(actual)."
}
diff --git a/PlexBar/Services/PlexDeviceIdentityStore.swift b/PlexBar/Services/PlexDeviceIdentityStore.swift
new file mode 100644
index 0000000..e253a85
--- /dev/null
+++ b/PlexBar/Services/PlexDeviceIdentityStore.swift
@@ -0,0 +1,88 @@
+import PlexModels
+import Foundation
+
+protocol PlexDeviceIdentityProviding: Actor {
+ func loadOrCreateIdentity() async throws -> PlexDeviceSigningIdentity
+}
+
+protocol PlexDeviceIdentityPersisting: Sendable {
+ func read(account: String) async throws -> String?
+ func write(_ value: String, account: String) async throws
+ func delete(account: String) async throws
+}
+
+extension KeychainStore: PlexDeviceIdentityPersisting {}
+
+actor PlexKeychainDeviceIdentityStore: PlexDeviceIdentityProviding {
+ private let keychain: any PlexDeviceIdentityPersisting
+
+ init(
+ keychain: any PlexDeviceIdentityPersisting = KeychainStore(
+ service: AppConstants.bundleIdentifier
+ )
+ ) {
+ self.keychain = keychain
+ }
+
+ func loadOrCreateIdentity() async throws -> PlexDeviceSigningIdentity {
+ async let storedKeyIDValue = keychain.read(account: KeychainAccounts.jwtKeyID)
+ async let storedPrivateKeyValue = keychain.read(account: KeychainAccounts.jwtPrivateKey)
+ let storedIdentity = try await (storedKeyIDValue, storedPrivateKeyValue)
+ let storedKeyID = storedIdentity.0?.nilIfBlank
+ let storedPrivateKey = storedIdentity.1?.nilIfBlank
+
+ switch (storedKeyID, storedPrivateKey) {
+ case (.none, .none):
+ let identity = try PlexDeviceSigningIdentity.generate()
+ do {
+ try await keychain.write(identity.keyID, account: KeychainAccounts.jwtKeyID)
+ try await keychain.write(
+ identity.privateKeyRepresentation.base64EncodedString(),
+ account: KeychainAccounts.jwtPrivateKey
+ )
+ async let persistedKeyID = keychain.read(account: KeychainAccounts.jwtKeyID)
+ async let persistedPrivateKey = keychain.read(
+ account: KeychainAccounts.jwtPrivateKey
+ )
+ let persistedIdentity = try await (persistedKeyID, persistedPrivateKey)
+ guard persistedIdentity.0 == identity.keyID,
+ persistedIdentity.1
+ == identity.privateKeyRepresentation.base64EncodedString() else {
+ throw PlexJWTError.deviceIdentityPersistenceFailed
+ }
+ } catch {
+ try? await keychain.delete(account: KeychainAccounts.jwtKeyID)
+ try? await keychain.delete(account: KeychainAccounts.jwtPrivateKey)
+ throw error
+ }
+ return identity
+ case (.some(let keyID), .some(let encodedPrivateKey)):
+ guard let privateKey = Data(base64Encoded: encodedPrivateKey) else {
+ throw PlexJWTError.incompleteDeviceIdentity
+ }
+ return try PlexDeviceSigningIdentity(
+ keyID: keyID,
+ privateKeyRepresentation: privateKey
+ )
+ default:
+ throw PlexJWTError.incompleteDeviceIdentity
+ }
+ }
+}
+
+actor PlexMemoryDeviceIdentityStore: PlexDeviceIdentityProviding {
+ private var identity: PlexDeviceSigningIdentity?
+
+ init(identity: PlexDeviceSigningIdentity? = nil) {
+ self.identity = identity
+ }
+
+ func loadOrCreateIdentity() async throws -> PlexDeviceSigningIdentity {
+ if let identity {
+ return identity
+ }
+ let identity = try PlexDeviceSigningIdentity.generate()
+ self.identity = identity
+ return identity
+ }
+}
diff --git a/PlexBar/Services/PlexDownloadTransferSession.swift b/PlexBar/Services/PlexDownloadTransferSession.swift
new file mode 100644
index 0000000..c5107e8
--- /dev/null
+++ b/PlexBar/Services/PlexDownloadTransferSession.swift
@@ -0,0 +1,282 @@
+import Foundation
+
+struct PlexDownloadTransferSession: Sendable {
+ typealias CreateTask = @Sendable (URLRequest, String) -> Int?
+ typealias Tasks = @Sendable () async -> [PlexDownloadTransferTaskSnapshot]
+ typealias TaskAction = @Sendable (Int) async -> Void
+
+ let events: AsyncStream
+ private let createTaskImplementation: CreateTask
+ private let tasksImplementation: Tasks
+ private let resumeTaskImplementation: TaskAction
+ private let suspendTaskImplementation: TaskAction
+ private let cancelTaskImplementation: TaskAction
+
+ init(
+ events: AsyncStream,
+ createTask: @escaping CreateTask,
+ tasks: @escaping Tasks,
+ resumeTask: @escaping TaskAction,
+ cancelTask: @escaping TaskAction,
+ suspendTask: @escaping TaskAction = { _ in }
+ ) {
+ self.events = events
+ createTaskImplementation = createTask
+ tasksImplementation = tasks
+ resumeTaskImplementation = resumeTask
+ suspendTaskImplementation = suspendTask
+ cancelTaskImplementation = cancelTask
+ }
+
+ func createTask(with request: URLRequest, transferID: UUID) -> Int? {
+ createTaskImplementation(request, transferID.uuidString)
+ }
+
+ func tasks() async -> [PlexDownloadTransferTaskSnapshot] {
+ await tasksImplementation()
+ }
+
+ func resumeTask(withIdentifier identifier: Int) async {
+ await resumeTaskImplementation(identifier)
+ }
+
+ func suspendTask(withIdentifier identifier: Int) async {
+ await suspendTaskImplementation(identifier)
+ }
+
+ func cancelTask(withIdentifier identifier: Int) async {
+ await cancelTaskImplementation(identifier)
+ }
+
+ static func background(
+ identifier: String,
+ handoffStore: PlexDownloadHandoffStore
+ ) -> PlexDownloadTransferSession {
+ let stream = AsyncStream.makeStream(of: PlexDownloadTransferEvent.self)
+ let delegate = PlexDownloadURLSessionDelegate(
+ handoffStore: handoffStore,
+ onEvent: { event in
+ stream.continuation.yield(event)
+ }
+ )
+ let configuration = backgroundConfiguration(identifier: identifier)
+
+ let queue = OperationQueue()
+ queue.name = "\(identifier).delegate"
+ queue.maxConcurrentOperationCount = 1
+ let owner = PlexDownloadURLSessionOwner(
+ configuration: configuration,
+ delegate: delegate,
+ delegateQueue: queue
+ )
+
+ return PlexDownloadTransferSession(
+ events: stream.stream,
+ createTask: { request, description in
+ owner.createTask(with: request, description: description)
+ },
+ tasks: {
+ await owner.tasks()
+ },
+ resumeTask: { identifier in
+ await owner.resumeTask(withIdentifier: identifier)
+ },
+ cancelTask: { identifier in
+ await owner.cancelTask(withIdentifier: identifier)
+ },
+ suspendTask: { identifier in
+ await owner.suspendTask(withIdentifier: identifier)
+ }
+ )
+ }
+
+ static func backgroundConfiguration(
+ identifier: String
+ ) -> URLSessionConfiguration {
+ let configuration = URLSessionConfiguration.background(withIdentifier: identifier)
+ configuration.isDiscretionary = false
+ configuration.sessionSendsLaunchEvents = true
+ configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
+ configuration.urlCache = nil
+ configuration.httpCookieStorage = nil
+ return configuration
+ }
+
+ static func inert() -> PlexDownloadTransferSession {
+ let stream = AsyncStream.makeStream(of: PlexDownloadTransferEvent.self)
+ return PlexDownloadTransferSession(
+ events: stream.stream,
+ createTask: { _, _ in nil },
+ tasks: { [] },
+ resumeTask: { _ in },
+ cancelTask: { _ in },
+ suspendTask: { _ in }
+ )
+ }
+}
+
+private final class PlexDownloadURLSessionOwner: @unchecked Sendable {
+ private let session: URLSession
+
+ init(
+ configuration: URLSessionConfiguration,
+ delegate: URLSessionDelegate,
+ delegateQueue: OperationQueue
+ ) {
+ session = URLSession(
+ configuration: configuration,
+ delegate: delegate,
+ delegateQueue: delegateQueue
+ )
+ }
+
+ func createTask(with request: URLRequest, description: String) -> Int {
+ let task = session.downloadTask(with: request)
+ task.taskDescription = description
+ return task.taskIdentifier
+ }
+
+ func tasks() async -> [PlexDownloadTransferTaskSnapshot] {
+ await withCheckedContinuation { continuation in
+ session.getAllTasks { tasks in
+ continuation.resume(returning: tasks.map(Self.snapshot))
+ }
+ }
+ }
+
+ func resumeTask(withIdentifier identifier: Int) async {
+ guard let task = await task(withIdentifier: identifier) else {
+ return
+ }
+ task.resume()
+ }
+
+ func suspendTask(withIdentifier identifier: Int) async {
+ guard let task = await task(withIdentifier: identifier) else {
+ return
+ }
+ task.suspend()
+ }
+
+ func cancelTask(withIdentifier identifier: Int) async {
+ guard let task = await task(withIdentifier: identifier) else {
+ return
+ }
+ task.cancel()
+ }
+
+ private func task(withIdentifier identifier: Int) async -> URLSessionTask? {
+ await withCheckedContinuation { continuation in
+ session.getAllTasks { tasks in
+ continuation.resume(returning: tasks.first {
+ $0.taskIdentifier == identifier
+ })
+ }
+ }
+ }
+
+ private static func snapshot(_ task: URLSessionTask) -> PlexDownloadTransferTaskSnapshot {
+ PlexDownloadTransferTaskSnapshot(
+ taskIdentifier: task.taskIdentifier,
+ taskDescription: task.taskDescription,
+ state: state(task.state),
+ countOfBytesReceived: task.countOfBytesReceived,
+ countOfBytesExpectedToReceive: task.countOfBytesExpectedToReceive
+ )
+ }
+
+ private static func state(
+ _ state: URLSessionTask.State
+ ) -> PlexDownloadTransferTaskSnapshot.State {
+ switch state {
+ case .running:
+ .running
+ case .suspended:
+ .suspended
+ case .canceling:
+ .canceling
+ case .completed:
+ .completed
+ @unknown default:
+ .completed
+ }
+ }
+}
+
+private final class PlexDownloadURLSessionDelegate:
+ NSObject,
+ URLSessionDownloadDelegate,
+ @unchecked Sendable
+{
+ private let handoffStore: PlexDownloadHandoffStore
+ private let onEvent: @Sendable (PlexDownloadTransferEvent) -> Void
+
+ init(
+ handoffStore: PlexDownloadHandoffStore,
+ onEvent: @escaping @Sendable (PlexDownloadTransferEvent) -> Void
+ ) {
+ self.handoffStore = handoffStore
+ self.onEvent = onEvent
+ }
+
+ func urlSession(
+ _ session: URLSession,
+ downloadTask: URLSessionDownloadTask,
+ didFinishDownloadingTo location: URL
+ ) {
+ let result: Result
+ if let description = downloadTask.taskDescription,
+ let transferID = UUID(uuidString: description) {
+ result = handoffStore.accept(
+ temporaryFileURL: location,
+ transferID: transferID,
+ response: downloadTask.response
+ )
+ } else {
+ result = .failure(.invalidTransferIdentity)
+ }
+ onEvent(.handoffCompleted(
+ taskIdentifier: downloadTask.taskIdentifier,
+ taskDescription: downloadTask.taskDescription,
+ result: result
+ ))
+ }
+
+ func urlSession(
+ _ session: URLSession,
+ downloadTask: URLSessionDownloadTask,
+ didWriteData bytesWritten: Int64,
+ totalBytesWritten: Int64,
+ totalBytesExpectedToWrite: Int64
+ ) {
+ onEvent(.progress(
+ taskIdentifier: downloadTask.taskIdentifier,
+ taskDescription: downloadTask.taskDescription,
+ bytesReceived: totalBytesWritten,
+ bytesExpected: totalBytesExpectedToWrite
+ ))
+ }
+
+ func urlSession(
+ _ session: URLSession,
+ task: URLSessionTask,
+ didCompleteWithError error: (any Error)?
+ ) {
+ let errorCode = error.map { ($0 as NSError).code }
+ onEvent(.taskCompleted(
+ taskIdentifier: task.taskIdentifier,
+ taskDescription: task.taskDescription,
+ errorCode: errorCode
+ ))
+ }
+
+ func urlSession(
+ _ session: URLSession,
+ taskIsWaitingForConnectivity task: URLSessionTask
+ ) {
+ onEvent(.waitingForConnectivity(
+ taskIdentifier: task.taskIdentifier,
+ taskDescription: task.taskDescription
+ ))
+ }
+}
diff --git a/Sources/PlexBar/Services/PlexGeoIPClient.swift b/PlexBar/Services/PlexGeoIPClient.swift
similarity index 99%
rename from Sources/PlexBar/Services/PlexGeoIPClient.swift
rename to PlexBar/Services/PlexGeoIPClient.swift
index 17e0df8..39de8f7 100644
--- a/Sources/PlexBar/Services/PlexGeoIPClient.swift
+++ b/PlexBar/Services/PlexGeoIPClient.swift
@@ -1,3 +1,4 @@
+import PlexModels
import Foundation
struct PlexGeoIPClient {
diff --git a/PlexBar/Services/PlexImageClient.swift b/PlexBar/Services/PlexImageClient.swift
new file mode 100644
index 0000000..99301ab
--- /dev/null
+++ b/PlexBar/Services/PlexImageClient.swift
@@ -0,0 +1,392 @@
+import PlexModels
+import AppKit
+import CoreGraphics
+import Foundation
+
+struct PlexFetchedImage {
+ let image: NSImage
+ let sourceURL: URL
+}
+
+struct PlexFetchedCGImage {
+ let image: CGImage
+ let sourceURL: URL
+}
+
+struct PlexImageClient: Sendable {
+ // Share one transport for image loaders created throughout the app. Mock
+ // images must remain loadable after cache eviction and at any requested size.
+ static let defaultSession: URLSession = {
+ #if DEBUG && os(macOS)
+ PlexAppRuntime.makeImageSession(arguments: ProcessInfo.processInfo.arguments)
+ #else
+ URLSession.shared
+ #endif
+ }()
+
+ private let session: URLSession
+ private let cache: PlexImageMemoryCache
+ private let requestCoordinator: PlexImageRequestCoordinator
+
+ init(
+ session: URLSession = PlexImageClient.defaultSession,
+ cache: PlexImageMemoryCache = .shared,
+ requestCoordinator: PlexImageRequestCoordinator = .shared
+ ) {
+ self.session = session
+ self.cache = cache
+ self.requestCoordinator = requestCoordinator
+ }
+
+ func cachedImage(
+ from urls: [URL],
+ token: String?,
+ maximumPixelSize: Int? = nil
+ ) -> NSImage? {
+ cachedImageResult(
+ from: urls,
+ token: token,
+ maximumPixelSize: maximumPixelSize
+ )?.image
+ }
+
+ func cachedImageResult(
+ from urls: [URL],
+ token: String?,
+ maximumPixelSize: Int? = nil
+ ) -> PlexFetchedImage? {
+ for url in urls {
+ if let image = cache.image(for: cacheKey(
+ url: url,
+ token: token,
+ maximumPixelSize: maximumPixelSize
+ )) {
+ return PlexFetchedImage(image: image, sourceURL: url)
+ }
+ }
+
+ return nil
+ }
+
+ func cachedPalette(for url: URL, token: String?) -> PlexArtworkPalette? {
+ cache.palette(for: cacheKey(url: url, token: token))
+ }
+
+ func cachePalette(_ palette: PlexArtworkPalette, for url: URL, token: String?) {
+ cache.insert(palette, for: cacheKey(url: url, token: token))
+ }
+
+ func cachedCGImageResult(
+ from urls: [URL],
+ token: String?,
+ maximumPixelSize: Int? = nil
+ ) -> PlexFetchedCGImage? {
+ for url in urls {
+ if let image = cache.cgImage(for: cacheKey(
+ url: url,
+ token: token,
+ maximumPixelSize: maximumPixelSize
+ )) {
+ return PlexFetchedCGImage(image: image, sourceURL: url)
+ }
+ }
+
+ return nil
+ }
+
+ func fetchImage(
+ from urls: [URL],
+ token: String?,
+ clientContext: PlexClientContext,
+ maximumPixelSize: Int? = nil
+ ) async -> NSImage? {
+ await fetchImageResult(
+ from: urls,
+ token: token,
+ clientContext: clientContext,
+ maximumPixelSize: maximumPixelSize
+ )?.image
+ }
+
+ func fetchImageResult(
+ from urls: [URL],
+ token: String?,
+ clientContext: PlexClientContext,
+ maximumPixelSize: Int? = nil
+ ) async -> PlexFetchedImage? {
+ guard let result = await fetchCGImageResult(
+ from: urls,
+ token: token,
+ clientContext: clientContext,
+ maximumPixelSize: maximumPixelSize
+ ) else {
+ return nil
+ }
+
+ return PlexFetchedImage(
+ image: NSImage(
+ cgImage: result.image,
+ size: NSSize(width: result.image.width, height: result.image.height)
+ ),
+ sourceURL: result.sourceURL
+ )
+ }
+
+ func fetchCGImageResult(
+ from urls: [URL],
+ token: String?,
+ clientContext: PlexClientContext,
+ maximumPixelSize: Int? = nil
+ ) async -> PlexFetchedCGImage? {
+ let requestBuilder = PlexRequestBuilder(clientContext: clientContext)
+
+ for url in urls {
+ let cacheKey = cacheKey(
+ url: url,
+ token: token,
+ maximumPixelSize: maximumPixelSize
+ )
+ if let image = cache.cgImage(for: cacheKey) {
+ return PlexFetchedCGImage(image: image, sourceURL: url)
+ }
+
+ let request: URLRequest
+ if token?.nilIfBlank != nil {
+ request = requestBuilder.request(
+ url: url,
+ accept: "image/*",
+ token: token
+ )
+ } else {
+ var publicRequest = URLRequest(url: url)
+ publicRequest.setValue("image/*", forHTTPHeaderField: "Accept")
+ request = publicRequest
+ }
+
+ guard let image = await requestCoordinator.image(for: cacheKey, operation: {
+ let data: Data
+ do {
+ if url.isFileURL {
+ data = try await Self.localImageData(at: url)
+ } else {
+ let (responseData, response) = try await session.data(for: request)
+ guard let httpResponse = response as? HTTPURLResponse,
+ (200..<300).contains(httpResponse.statusCode) else {
+ return nil
+ }
+ data = responseData
+ }
+ } catch {
+ return nil
+ }
+
+ guard let imageBox = await PlexImageDecoder.decodeCGImage(
+ from: data,
+ maximumPixelSize: maximumPixelSize
+ ) else {
+ return nil
+ }
+
+ cache.insert(imageBox.image, for: cacheKey)
+ return imageBox
+ }) else {
+ continue
+ }
+
+ return PlexFetchedCGImage(image: image, sourceURL: url)
+ }
+
+ return nil
+ }
+
+ @concurrent
+ private static func localImageData(at url: URL) async throws -> Data {
+ try Data(contentsOf: url)
+ }
+
+ private func cacheKey(
+ url: URL,
+ token: String?,
+ maximumPixelSize: Int? = nil
+ ) -> String {
+ let sizeSuffix = maximumPixelSize.map { "|pixels=\($0)" } ?? ""
+ if let token = token?.nilIfBlank {
+ return "\(url.absoluteString)|\(token)\(sizeSuffix)"
+ }
+
+ return url.absoluteString + sizeSuffix
+ }
+
+}
+
+final class PlexImageMemoryCache: @unchecked Sendable {
+ static let shared = PlexImageMemoryCache()
+
+ private let lock = NSLock()
+ private let cgImageCache = NSCache()
+ private let paletteCache = NSCache()
+ private let imageCountLimit: Int
+ private let imageCostLimit: Int
+ private let paletteCountLimit: Int
+ private var imageCosts: [String: Int] = [:]
+ private var imageLRU: [String] = []
+ private var imageCost = 0
+ private var paletteLRU: [String] = []
+
+ init(
+ imageCountLimit: Int = 256,
+ imageCostLimit: Int = 96 * 1_024 * 1_024,
+ paletteCountLimit: Int = 512
+ ) {
+ self.imageCountLimit = max(0, imageCountLimit)
+ self.imageCostLimit = max(0, imageCostLimit)
+ self.paletteCountLimit = max(0, paletteCountLimit)
+ cgImageCache.countLimit = self.imageCountLimit
+ cgImageCache.totalCostLimit = self.imageCostLimit
+ paletteCache.countLimit = self.paletteCountLimit
+ }
+
+ func image(for key: String) -> NSImage? {
+ guard let image = cgImage(for: key) else {
+ return nil
+ }
+ return NSImage(
+ cgImage: image,
+ size: NSSize(width: image.width, height: image.height)
+ )
+ }
+
+ func insert(_ image: NSImage, for key: String) {
+ guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
+ return
+ }
+ insert(cgImage, for: key)
+ }
+
+ func cgImage(for key: String) -> CGImage? {
+ lock.lock()
+ defer { lock.unlock() }
+
+ guard let image = cgImageCache.object(forKey: key as NSString)?.image else {
+ removeImageMetadata(for: key)
+ return nil
+ }
+ touch(key, in: &imageLRU)
+ return image
+ }
+
+ func insert(_ image: CGImage, for key: String) {
+ let cost = image.bytesPerRow * image.height
+ lock.lock()
+ defer { lock.unlock() }
+
+ pruneEvictedImages()
+ removeImageMetadata(for: key)
+ cgImageCache.removeObject(forKey: key as NSString)
+
+ guard imageCountLimit > 0,
+ imageCostLimit > 0,
+ cost <= imageCostLimit else {
+ return
+ }
+
+ while imageLRU.count >= imageCountLimit || imageCost + cost > imageCostLimit {
+ guard let evictedKey = imageLRU.first else {
+ break
+ }
+ cgImageCache.removeObject(forKey: evictedKey as NSString)
+ removeImageMetadata(for: evictedKey)
+ }
+
+ cgImageCache.setObject(PlexCGImageBox(image), forKey: key as NSString, cost: cost)
+ imageCosts[key] = cost
+ imageLRU.append(key)
+ imageCost += cost
+ }
+
+ func palette(for key: String) -> PlexArtworkPalette? {
+ lock.lock()
+ defer { lock.unlock() }
+
+ guard let palette = paletteCache.object(forKey: key as NSString)?.palette else {
+ paletteLRU.removeAll { $0 == key }
+ return nil
+ }
+ touch(key, in: &paletteLRU)
+ return palette
+ }
+
+ func insert(_ palette: PlexArtworkPalette, for key: String) {
+ lock.lock()
+ defer { lock.unlock() }
+
+ pruneEvictedPalettes()
+ paletteCache.removeObject(forKey: key as NSString)
+ paletteLRU.removeAll { $0 == key }
+ guard paletteCountLimit > 0 else {
+ return
+ }
+
+ while paletteLRU.count >= paletteCountLimit, let evictedKey = paletteLRU.first {
+ paletteCache.removeObject(forKey: evictedKey as NSString)
+ paletteLRU.removeFirst()
+ }
+
+ paletteCache.setObject(PlexArtworkPaletteBox(palette), forKey: key as NSString)
+ paletteLRU.append(key)
+ }
+
+ private func pruneEvictedImages() {
+ for key in imageLRU where cgImageCache.object(forKey: key as NSString) == nil {
+ if let cost = imageCosts.removeValue(forKey: key) {
+ imageCost -= cost
+ }
+ }
+ imageLRU.removeAll { imageCosts[$0] == nil }
+ }
+
+ private func pruneEvictedPalettes() {
+ paletteLRU.removeAll { paletteCache.object(forKey: $0 as NSString) == nil }
+ }
+
+ private func removeImageMetadata(for key: String) {
+ if let cost = imageCosts.removeValue(forKey: key) {
+ imageCost -= cost
+ }
+ imageLRU.removeAll { $0 == key }
+ }
+
+ private func touch(_ key: String, in keys: inout [String]) {
+ keys.removeAll { $0 == key }
+ keys.append(key)
+ }
+}
+
+actor PlexImageRequestCoordinator {
+ static let shared = PlexImageRequestCoordinator()
+
+ private var inFlight: [String: Task] = [:]
+
+ func image(
+ for key: String,
+ operation: @escaping @Sendable () async -> PlexCGImageBox?
+ ) async -> CGImage? {
+ if let existingTask = inFlight[key] {
+ return await existingTask.value?.image
+ }
+
+ let task = Task(operation: operation)
+ inFlight[key] = task
+ let result = await task.value
+ inFlight[key] = nil
+ return result?.image
+ }
+}
+
+private final class PlexArtworkPaletteBox {
+ let palette: PlexArtworkPalette
+
+ init(_ palette: PlexArtworkPalette) {
+ self.palette = palette
+ }
+}
diff --git a/Sources/PlexBar/Services/PlexLoginItemService.swift b/PlexBar/Services/PlexLoginItemService.swift
similarity index 100%
rename from Sources/PlexBar/Services/PlexLoginItemService.swift
rename to PlexBar/Services/PlexLoginItemService.swift
diff --git a/Sources/PlexBar/Services/PlexSessionEventsClient.swift b/PlexBar/Services/PlexSessionEventsClient.swift
similarity index 87%
rename from Sources/PlexBar/Services/PlexSessionEventsClient.swift
rename to PlexBar/Services/PlexSessionEventsClient.swift
index 8096450..87766bf 100644
--- a/Sources/PlexBar/Services/PlexSessionEventsClient.swift
+++ b/PlexBar/Services/PlexSessionEventsClient.swift
@@ -1,4 +1,6 @@
+import PlexModels
import Foundation
+import OSLog
struct PlexSessionEventsClient {
typealias MonitorHandler = @Sendable (PlexSessionEvent) async throws -> Void
@@ -6,6 +8,7 @@ struct PlexSessionEventsClient {
private static let handshakeTimeout: Duration = .seconds(5)
private static let heartbeatInterval: Duration = .seconds(30)
private static let heartbeatTimeout: Duration = .seconds(10)
+ private static let logger = Logger(subsystem: "com.crapshack.PlexBar", category: "SessionEvents")
private let monitorImplementation: MonitorImplementation
@@ -77,13 +80,40 @@ struct PlexSessionEventsClient {
static func decodeEventsIfPossible(from data: Data) -> [PlexSessionEvent] {
do {
return try decodeEvents(from: data)
- } catch is DecodingError {
+ } catch let error as DecodingError {
+ logger.error("Unable to decode Plex notification: \(decodingFailureSummary(error), privacy: .public)")
return []
} catch {
+ logger.error("Unable to decode Plex notification: \(error.localizedDescription, privacy: .private)")
return []
}
}
+ static func decodingFailureSummary(_ error: DecodingError) -> String {
+ let reason: String
+ let path: [any CodingKey]
+ switch error {
+ case .typeMismatch(_, let context):
+ reason = "type mismatch"
+ path = context.codingPath
+ case .valueNotFound(_, let context):
+ reason = "missing value"
+ path = context.codingPath
+ case .keyNotFound(let key, let context):
+ reason = "missing key"
+ path = context.codingPath + [key]
+ case .dataCorrupted(let context):
+ reason = "invalid data"
+ path = context.codingPath
+ @unknown default:
+ return "unknown decoding error"
+ }
+
+ // Do not log the payload or debugDescription, which may contain private values.
+ let field = path.map(\.stringValue).joined(separator: ".")
+ return "\(reason) at \(field.isEmpty ? "root" : field)"
+ }
+
static func confirmHandshake(
sendPing: (@escaping @Sendable (Error?) -> Void) -> Void,
timeout: Duration = handshakeTimeout
diff --git a/Sources/PlexBar/Services/PlexUpdateService.swift b/PlexBar/Services/PlexUpdateService.swift
similarity index 100%
rename from Sources/PlexBar/Services/PlexUpdateService.swift
rename to PlexBar/Services/PlexUpdateService.swift
diff --git a/PlexBar/Stores/PlexAccountJWTManager.swift b/PlexBar/Stores/PlexAccountJWTManager.swift
new file mode 100644
index 0000000..1846b0f
--- /dev/null
+++ b/PlexBar/Stores/PlexAccountJWTManager.swift
@@ -0,0 +1,154 @@
+import Foundation
+
+struct PlexPreparedAccountToken: Equatable, Sendable {
+ let token: String
+ let expiresAt: Date
+ let refreshAt: Date
+}
+
+@MainActor
+protocol PlexAccountJWTStorage: AnyObject {
+ var clientIdentifier: String { get }
+ var storedAccountToken: String { get }
+ var registeredJWTKeyID: String? { get }
+
+ func persistAccountToken(_ token: String) async throws
+ func markJWTKeyRegistered(keyID: String)
+}
+
+@MainActor
+final class PlexAccountJWTManager {
+ nonisolated static let requestedScope = "username,email,friendly_name"
+ nonisolated static let defaultRefreshLeadTime: TimeInterval = 24 * 60 * 60
+
+ private let storage: any PlexAccountJWTStorage
+ private let client: any PlexAccountJWTClient
+ private let deviceIdentityStore: any PlexDeviceIdentityProviding
+ private let refreshLeadTime: TimeInterval
+ private let now: @Sendable () -> Date
+ private var preparationTask: Task?
+
+ init(
+ storage: any PlexAccountJWTStorage,
+ client: any PlexAccountJWTClient,
+ deviceIdentityStore: any PlexDeviceIdentityProviding,
+ refreshLeadTime: TimeInterval = PlexAccountJWTManager.defaultRefreshLeadTime,
+ now: @escaping @Sendable () -> Date = { Date() }
+ ) {
+ self.storage = storage
+ self.client = client
+ self.deviceIdentityStore = deviceIdentityStore
+ self.refreshLeadTime = refreshLeadTime
+ self.now = now
+ }
+
+ func prepareAccountToken(forceRefresh: Bool = false) async throws -> PlexPreparedAccountToken {
+ if let preparationTask {
+ return try await preparationTask.value
+ }
+
+ let task = Task { @MainActor in
+ try await prepareAccountTokenNow(forceRefresh: forceRefresh)
+ }
+ preparationTask = task
+ defer { preparationTask = nil }
+ return try await task.value
+ }
+
+ func acceptNewAccountToken(
+ _ token: String,
+ registeredKeyID: String
+ ) async throws -> PlexPreparedAccountToken {
+ preparationTask?.cancel()
+ preparationTask = nil
+ let preparedToken = try validateIssuedJWT(token, issuedAt: now())
+ storage.markJWTKeyRegistered(keyID: registeredKeyID)
+ try await storage.persistAccountToken(preparedToken.token)
+ return preparedToken
+ }
+
+ func recoverRejectedAccountToken(_ rejectedToken: String) async throws -> PlexPreparedAccountToken {
+ if storage.storedAccountToken != rejectedToken {
+ return try await prepareAccountToken()
+ }
+
+ return try await prepareAccountToken(forceRefresh: true)
+ }
+
+ private func prepareAccountTokenNow(forceRefresh: Bool) async throws -> PlexPreparedAccountToken {
+ let storedToken = storage.storedAccountToken
+ guard !storedToken.isEmpty else {
+ throw PlexJWTError.missingAccountToken
+ }
+
+ let currentDate = now()
+ switch try PlexAccountToken(token: storedToken) {
+ case .legacy:
+ let identity = try await deviceIdentityStore.loadOrCreateIdentity()
+ if storage.registeredJWTKeyID != identity.keyID {
+ try await client.registerJWK(
+ identity.publicJWK(includeUse: true),
+ legacyToken: storedToken,
+ clientContext: clientContext
+ )
+ storage.markJWTKeyRegistered(keyID: identity.keyID)
+ }
+ return try await issueAccountToken(identity: identity, issuedAt: currentDate)
+
+ case .jwt(let expiresAt):
+ if !forceRefresh,
+ expiresAt.timeIntervalSince(currentDate) > refreshLeadTime {
+ return preparedToken(token: storedToken, expiresAt: expiresAt)
+ }
+
+ let identity = try await deviceIdentityStore.loadOrCreateIdentity()
+ return try await issueAccountToken(identity: identity, issuedAt: currentDate)
+ }
+ }
+
+ private func issueAccountToken(
+ identity: PlexDeviceSigningIdentity,
+ issuedAt: Date
+ ) async throws -> PlexPreparedAccountToken {
+ let nonce = try await client.fetchJWTNonce(clientContext: clientContext)
+ let deviceJWT = try identity.signedDeviceJWT(
+ clientIdentifier: storage.clientIdentifier,
+ nonce: nonce,
+ scope: Self.requestedScope,
+ issuedAt: issuedAt
+ )
+ let accountToken = try await client.exchangeDeviceJWT(
+ deviceJWT,
+ clientContext: clientContext
+ )
+ let preparedToken = try validateIssuedJWT(accountToken, issuedAt: issuedAt)
+ storage.markJWTKeyRegistered(keyID: identity.keyID)
+ try await storage.persistAccountToken(preparedToken.token)
+ return preparedToken
+ }
+
+ private func validateIssuedJWT(
+ _ token: String,
+ issuedAt: Date
+ ) throws -> PlexPreparedAccountToken {
+ guard case .jwt(let expiresAt) = try PlexAccountToken(token: token) else {
+ throw PlexJWTError.expectedAccountJWT
+ }
+ guard expiresAt.timeIntervalSince(issuedAt) > refreshLeadTime else {
+ throw PlexJWTError.accountTokenExpiresTooSoon
+ }
+ return preparedToken(token: token, expiresAt: expiresAt)
+ }
+
+ private func preparedToken(token: String, expiresAt: Date) -> PlexPreparedAccountToken {
+ PlexPreparedAccountToken(
+ token: token,
+ expiresAt: expiresAt,
+ refreshAt: expiresAt.addingTimeInterval(-refreshLeadTime)
+ )
+ }
+
+ private var clientContext: PlexClientContext {
+ PlexClientContext(clientIdentifier: storage.clientIdentifier)
+ }
+}
diff --git a/PlexBar/Stores/PlexAuthStore.swift b/PlexBar/Stores/PlexAuthStore.swift
new file mode 100644
index 0000000..e5968fa
--- /dev/null
+++ b/PlexBar/Stores/PlexAuthStore.swift
@@ -0,0 +1,340 @@
+import PlexModels
+import AppKit
+import Foundation
+import Observation
+
+@MainActor
+@Observable
+final class PlexAuthStore {
+ private let settings: PlexSettingsStore
+ private let connectionStore: PlexConnectionStore
+ private let sessionStore: PlexSessionStore
+ private let historyStore: PlexHistoryStore
+ private let libraryStore: PlexLibraryStore
+ private let client: PlexAuthClient
+ private let deviceIdentityStore: any PlexDeviceIdentityProviding
+ private let accountJWTManager: PlexAccountJWTManager
+ private var signInTask: Task?
+ private var accountTokenRefreshTask: Task?
+ private var didLoadCredentials = false
+
+ var authenticatedUser: PlexAuthenticatedUser?
+ var availableServers: [PlexServerResource] = []
+ var isAuthenticating = false
+ var isLoadingAuthenticatedUser = false
+ var isLoadingServers = false
+ var accountErrorMessage: String?
+ var statusMessage: String?
+ var errorMessage: String?
+ var remainingSeconds: Int?
+ private(set) var canCancelSignIn = false
+
+ var signInProgressMessage: String? {
+ guard let statusMessage else {
+ return nil
+ }
+ guard let remainingSeconds else {
+ return statusMessage
+ }
+
+ let minutes = remainingSeconds / 60
+ let seconds = remainingSeconds % 60
+ return "\(statusMessage) \(minutes):" + String(format: "%02d", seconds)
+ }
+
+ init(
+ settings: PlexSettingsStore,
+ connectionStore: PlexConnectionStore,
+ sessionStore: PlexSessionStore,
+ historyStore: PlexHistoryStore,
+ libraryStore: PlexLibraryStore,
+ client: PlexAuthClient = PlexAuthClient(),
+ deviceIdentityStore: any PlexDeviceIdentityProviding = PlexKeychainDeviceIdentityStore(),
+ accountJWTManager: PlexAccountJWTManager? = nil
+ ) {
+ self.settings = settings
+ self.connectionStore = connectionStore
+ self.sessionStore = sessionStore
+ self.historyStore = historyStore
+ self.libraryStore = libraryStore
+ self.client = client
+ self.deviceIdentityStore = deviceIdentityStore
+ self.accountJWTManager = accountJWTManager ?? PlexAccountJWTManager(
+ storage: settings,
+ client: client,
+ deviceIdentityStore: deviceIdentityStore
+ )
+ }
+
+ func credentialsDidLoad() async {
+ guard settings.hasLoadedCredentials, !didLoadCredentials else {
+ return
+ }
+ didLoadCredentials = true
+ if settings.hasAuthenticatedAccount {
+ await refreshAuthenticatedState(autoSelectStoredServer: true)
+ }
+ }
+
+ func refreshAuthenticatedUser() async {
+ guard settings.hasAuthenticatedAccount else {
+ authenticatedUser = nil
+ accountErrorMessage = nil
+ isLoadingAuthenticatedUser = false
+ return
+ }
+
+ await loadAuthenticatedUser()
+ }
+
+ func startSignIn() {
+ guard !isAuthenticating else {
+ return
+ }
+
+ signInTask?.cancel()
+ signInTask = Task {
+ await runSignIn()
+ }
+ }
+
+ func cancelSignIn() {
+ guard canCancelSignIn else {
+ return
+ }
+ signInTask?.cancel()
+ signInTask = nil
+ clearSignInPresentation()
+ }
+
+ func refreshServers(autoSelectStoredServer: Bool = false) async {
+ guard settings.hasAuthenticatedAccount else {
+ availableServers = []
+ return
+ }
+
+ await loadServers(autoSelectStoredServer: autoSelectStoredServer)
+ }
+
+ func selectServer(withID serverID: String) {
+ guard let server = availableServers.first(where: { $0.id == serverID }) else {
+ return
+ }
+
+ selectServer(server)
+ }
+
+ func signOut() {
+ signInTask?.cancel()
+ accountTokenRefreshTask?.cancel()
+ isAuthenticating = false
+ canCancelSignIn = false
+ isLoadingAuthenticatedUser = false
+ isLoadingServers = false
+ authenticatedUser = nil
+ accountErrorMessage = nil
+ statusMessage = nil
+ errorMessage = nil
+ remainingSeconds = nil
+ availableServers = []
+ settings.clearAuthentication()
+ connectionStore.updateAvailableServers([])
+ sessionStore.didChangeConfiguration()
+ historyStore.refreshNow()
+ }
+
+ private func selectServer(_ server: PlexServerResource) {
+ settings.saveServerSelection(server)
+ connectionStore.didSelectServer()
+ sessionStore.didChangeConfiguration()
+ historyStore.refreshNow()
+ }
+
+ private func refreshAuthenticatedState(autoSelectStoredServer: Bool) async {
+ async let authenticatedUserRefresh: Void = loadAuthenticatedUser()
+ async let serverRefresh: Void = loadServers(
+ autoSelectStoredServer: autoSelectStoredServer
+ )
+ _ = await (authenticatedUserRefresh, serverRefresh)
+ }
+
+ private func runSignIn() async {
+ isAuthenticating = true
+ canCancelSignIn = true
+ statusMessage = "Waiting for authentication in your browser…"
+ errorMessage = nil
+ remainingSeconds = nil
+
+ let clientIdentifier = settings.clientIdentifier
+ let clientContext = PlexClientContext(clientIdentifier: clientIdentifier)
+
+ do {
+ let identity = try await deviceIdentityStore.loadOrCreateIdentity()
+ let pin = try await client.createPin(
+ jwk: identity.publicJWK(includeUse: false),
+ clientContext: clientContext
+ )
+ let deviceJWT = try identity.signedDeviceJWT(clientIdentifier: clientIdentifier)
+ guard let authURL = clientContext.authURL(for: pin.code) else {
+ throw PlexAuthError.invalidAuthURL
+ }
+
+ NSWorkspace.shared.open(authURL)
+
+ for seconds in stride(from: 120, through: 1, by: -1) {
+ remainingSeconds = seconds
+
+ let currentPin = try await client.fetchPin(
+ id: String(pin.id),
+ deviceJWT: deviceJWT,
+ clientContext: clientContext
+ )
+ if let authToken = currentPin.authToken?.nilIfBlank {
+ try Task.checkCancellation()
+ canCancelSignIn = false
+ statusMessage = "Completing sign in…"
+ remainingSeconds = nil
+ let preparedToken = try await accountJWTManager.acceptNewAccountToken(
+ authToken,
+ registeredKeyID: identity.keyID
+ )
+ scheduleAccountTokenRefresh(preparedToken)
+ statusMessage = "Authentication successful."
+ remainingSeconds = nil
+ isAuthenticating = false
+ await refreshAuthenticatedState(autoSelectStoredServer: true)
+ return
+ }
+
+ try await Task.sleep(for: .seconds(1))
+ }
+
+ statusMessage = nil
+ errorMessage = "Authentication timed out. Please try again."
+ } catch {
+ if Task.isCancelled {
+ clearSignInPresentation()
+ return
+ }
+ statusMessage = nil
+ errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
+ }
+
+ remainingSeconds = nil
+ isAuthenticating = false
+ canCancelSignIn = false
+ }
+
+ private func clearSignInPresentation() {
+ isAuthenticating = false
+ canCancelSignIn = false
+ statusMessage = nil
+ errorMessage = nil
+ remainingSeconds = nil
+ }
+
+ private func loadAuthenticatedUser() async {
+ isLoadingAuthenticatedUser = true
+ accountErrorMessage = nil
+
+ do {
+ authenticatedUser = try await performAccountRequest { token in
+ try await client.fetchAuthenticatedUser(
+ userToken: token,
+ clientContext: PlexClientContext(clientIdentifier: settings.clientIdentifier)
+ )
+ }
+ } catch {
+ authenticatedUser = nil
+ accountErrorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
+ }
+
+ isLoadingAuthenticatedUser = false
+ }
+
+ private func loadServers(
+ autoSelectStoredServer: Bool
+ ) async {
+ isLoadingServers = true
+ errorMessage = nil
+
+ do {
+ let servers = try await performAccountRequest { token in
+ try await client.fetchServers(
+ userToken: token,
+ clientContext: PlexClientContext(clientIdentifier: settings.clientIdentifier)
+ )
+ }
+ guard !servers.isEmpty else {
+ throw PlexAuthError.noServersFound
+ }
+
+ availableServers = servers
+ connectionStore.updateAvailableServers(servers)
+
+ if autoSelectStoredServer,
+ let selectedServerIdentifier = settings.selectedServerIdentifier,
+ let storedServer = servers.first(where: { $0.id == selectedServerIdentifier }) {
+ selectServer(storedServer)
+ } else if settings.selectedServerIdentifier == nil
+ || !servers.contains(where: { $0.id == settings.selectedServerIdentifier }) {
+ selectServer(servers[0])
+ }
+
+ statusMessage = nil
+ } catch {
+ errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
+ }
+
+ isLoadingServers = false
+ }
+
+}
+
+private extension PlexAuthStore {
+ func performAccountRequest(
+ _ operation: (String) async throws -> Value
+ ) async throws -> Value {
+ let preparedToken = try await accountJWTManager.prepareAccountToken()
+ scheduleAccountTokenRefresh(preparedToken)
+
+ do {
+ return try await operation(preparedToken.token)
+ } catch let error as PlexAuthError where error.requiresTokenRefresh {
+ let refreshedToken = try await accountJWTManager.recoverRejectedAccountToken(
+ preparedToken.token
+ )
+ scheduleAccountTokenRefresh(refreshedToken)
+ do {
+ return try await operation(refreshedToken.token)
+ } catch let retryError as PlexAuthError where retryError.requiresTokenRefresh {
+ accountTokenRefreshTask?.cancel()
+ try await settings.saveAuthenticatedUserToken("")
+ throw retryError
+ }
+ }
+ }
+
+ func scheduleAccountTokenRefresh(_ preparedToken: PlexPreparedAccountToken) {
+ accountTokenRefreshTask?.cancel()
+ let delay = max(preparedToken.refreshAt.timeIntervalSinceNow, 0)
+ accountTokenRefreshTask = Task { [weak self] in
+ do {
+ try await Task.sleep(for: .seconds(delay))
+ } catch {
+ return
+ }
+ guard let self else {
+ return
+ }
+
+ do {
+ let refreshedToken = try await accountJWTManager.prepareAccountToken(forceRefresh: true)
+ scheduleAccountTokenRefresh(refreshedToken)
+ accountErrorMessage = nil
+ } catch {
+ accountErrorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
+ }
+ }
+ }
+}
diff --git a/PlexBar/Stores/PlexBrowserStore+Collections.swift b/PlexBar/Stores/PlexBrowserStore+Collections.swift
new file mode 100644
index 0000000..e5c5ac2
--- /dev/null
+++ b/PlexBar/Stores/PlexBrowserStore+Collections.swift
@@ -0,0 +1,165 @@
+import PlexModels
+import Foundation
+
+extension PlexBrowserStore {
+ func collections(in library: PlexLibrary) -> [PlexMediaItem] {
+ collectionItemsByLibraryID[library.id] ?? []
+ }
+
+ func isLoadingCollections(in library: PlexLibrary) -> Bool {
+ loadingCollectionLibraryIDs.contains(library.id)
+ }
+
+ func collectionsErrorMessage(in library: PlexLibrary) -> String? {
+ collectionErrorMessagesByLibraryID[library.id]
+ }
+
+ func hasMoreCollections(in library: PlexLibrary) -> Bool {
+ let itemCount = collectionItemsByLibraryID[library.id]?.count ?? 0
+ return itemCount < (collectionTotalSizesByLibraryID[library.id] ?? itemCount)
+ }
+
+ func loadCollections(in library: PlexLibrary, forceRefresh: Bool = false) async {
+ guard !loadingCollectionLibraryIDs.contains(library.id) else {
+ return
+ }
+ if !forceRefresh, collectionItemsByLibraryID[library.id] != nil {
+ return
+ }
+
+ loadingCollectionLibraryIDs.insert(library.id)
+ defer { loadingCollectionLibraryIDs.remove(library.id) }
+
+ do {
+ let page = try await fetchCollectionsPage(libraryID: library.id, start: 0)
+ collectionItemsByLibraryID[library.id] = page.items
+ collectionTotalSizesByLibraryID[library.id] = page.totalSize ?? page.items.count
+ collectionErrorMessagesByLibraryID[library.id] = nil
+ } catch is CancellationError {
+ return
+ } catch {
+ guard !Task.isCancelled else {
+ return
+ }
+ collectionErrorMessagesByLibraryID[library.id] = error.localizedDescription
+ }
+ }
+
+ func loadMoreCollectionsIfNeeded(
+ in library: PlexLibrary,
+ currentItem: PlexMediaItem
+ ) async {
+ guard collectionItemsByLibraryID[library.id]?.last?.id == currentItem.id,
+ hasMoreCollections(in: library),
+ !loadingCollectionLibraryIDs.contains(library.id) else {
+ return
+ }
+
+ loadingCollectionLibraryIDs.insert(library.id)
+ defer { loadingCollectionLibraryIDs.remove(library.id) }
+
+ do {
+ let existingItems = collectionItemsByLibraryID[library.id] ?? []
+ let page = try await fetchCollectionsPage(
+ libraryID: library.id,
+ start: existingItems.count
+ )
+ let existingIDs = Set(existingItems.map(\.id))
+ collectionItemsByLibraryID[library.id] = existingItems
+ + page.items.filter { !existingIDs.contains($0.id) }
+ collectionTotalSizesByLibraryID[library.id] = page.totalSize
+ ?? collectionTotalSizesByLibraryID[library.id]
+ collectionErrorMessagesByLibraryID[library.id] = nil
+ } catch is CancellationError {
+ return
+ } catch {
+ guard !Task.isCancelled else {
+ return
+ }
+ collectionErrorMessagesByLibraryID[library.id] = error.localizedDescription
+ }
+ }
+
+ func loadPlaylists(forceRefresh: Bool = false) async {
+ guard !isLoadingPlaylists else {
+ return
+ }
+ if !forceRefresh, playlistsTotalSize != nil {
+ return
+ }
+
+ isLoadingPlaylists = true
+ defer { isLoadingPlaylists = false }
+
+ do {
+ let page = try await fetchPlaylistsPage(start: 0)
+ playlists = page.items
+ playlistsTotalSize = page.totalSize ?? page.items.count
+ playlistsErrorMessage = nil
+ } catch is CancellationError {
+ return
+ } catch {
+ guard !Task.isCancelled else {
+ return
+ }
+ playlistsErrorMessage = error.localizedDescription
+ }
+ }
+
+ func loadMorePlaylistsIfNeeded(currentItem: PlexMediaItem) async {
+ guard playlists.last?.id == currentItem.id,
+ playlists.count < (playlistsTotalSize ?? playlists.count),
+ !isLoadingPlaylists else {
+ return
+ }
+
+ isLoadingPlaylists = true
+ defer { isLoadingPlaylists = false }
+
+ do {
+ let page = try await fetchPlaylistsPage(start: playlists.count)
+ let existingIDs = Set(playlists.map(\.id))
+ playlists += page.items.filter { !existingIDs.contains($0.id) }
+ playlistsTotalSize = page.totalSize ?? playlistsTotalSize
+ playlistsErrorMessage = nil
+ } catch is CancellationError {
+ return
+ } catch {
+ guard !Task.isCancelled else {
+ return
+ }
+ playlistsErrorMessage = error.localizedDescription
+ }
+ }
+}
+
+private extension PlexBrowserStore {
+ func fetchCollectionsPage(
+ libraryID: String,
+ start: Int
+ ) async throws -> PlexMediaPage {
+ try await connectionStore.perform { configuration in
+ try await client.fetchCollectionsPage(
+ libraryID: libraryID,
+ using: configuration,
+ start: start,
+ size: pageSize
+ )
+ }
+ }
+
+ func fetchPlaylistsPage(start: Int) async throws -> PlexMediaPage {
+ try await connectionStore.perform { configuration in
+ let endpoints = try await self.resolvedLibraryProviderEndpoints(using: configuration)
+ guard let playlistPath = endpoints.playlistPath else {
+ throw PlexAPIError.missingLibraryPlaylistFeature
+ }
+ return try await client.fetchPlaylistsPage(
+ endpointPath: playlistPath,
+ using: configuration,
+ start: start,
+ size: pageSize
+ )
+ }
+ }
+}
diff --git a/PlexBar/Stores/PlexBrowserStore+DetailDiscovery.swift b/PlexBar/Stores/PlexBrowserStore+DetailDiscovery.swift
new file mode 100644
index 0000000..4d137e9
--- /dev/null
+++ b/PlexBar/Stores/PlexBrowserStore+DetailDiscovery.swift
@@ -0,0 +1,44 @@
+import PlexModels
+import Foundation
+
+struct PlexDetailDiscoveryPresentation: Equatable {
+ let hasContent: Bool
+ let hasError: Bool
+ let isLoading: Bool
+
+ var isVisible: Bool {
+ hasContent || hasError || isLoading
+ }
+}
+
+extension PlexBrowserStore {
+ func detailDiscoveryPresentation(for item: PlexMediaItem) -> PlexDetailDiscoveryPresentation {
+ PlexDetailDiscoveryPresentation(
+ hasContent: !mediaExtras(for: item).isEmpty || !relatedHubs(for: item).isEmpty,
+ hasError: mediaExtrasErrorMessage(for: item) != nil
+ || relatedContentErrorMessage(for: item) != nil,
+ isLoading: isLoadingMediaExtras(for: item) || isLoadingRelatedContent(for: item)
+ )
+ }
+
+ func resetDetailDiscoveryContent() {
+ resetMediaExtras()
+ resetRelatedContent()
+ resetPeople()
+ }
+
+ func loadDetailDiscoveryContent(
+ for item: PlexMediaItem,
+ forceRefresh: Bool = false
+ ) async {
+ async let extrasLoad: Void = loadMediaExtras(
+ for: item,
+ forceRefresh: forceRefresh
+ )
+ async let relatedLoad: Void = loadRelatedContent(
+ for: item,
+ forceRefresh: forceRefresh
+ )
+ _ = await (extrasLoad, relatedLoad)
+ }
+}
diff --git a/PlexBar/Stores/PlexBrowserStore+FilterValues.swift b/PlexBar/Stores/PlexBrowserStore+FilterValues.swift
new file mode 100644
index 0000000..80b45b1
--- /dev/null
+++ b/PlexBar/Stores/PlexBrowserStore+FilterValues.swift
@@ -0,0 +1,104 @@
+import Foundation
+
+extension PlexBrowserStore {
+ func filterValues(for filter: PlexLibraryFilterDefinition) -> [PlexLibraryFilterValue] {
+ guard let cacheKey = currentFilterValueCacheKey(for: filter) else {
+ return []
+ }
+ return libraryFilterValuesByCacheKey[cacheKey] ?? []
+ }
+
+ func isLoadingFilterValues(for filter: PlexLibraryFilterDefinition) -> Bool {
+ guard let cacheKey = currentFilterValueCacheKey(for: filter) else {
+ return false
+ }
+ return loadingLibraryFilterValueKeys.contains(cacheKey)
+ }
+
+ func hasLoadedFilterValues(for filter: PlexLibraryFilterDefinition) -> Bool {
+ guard let cacheKey = currentFilterValueCacheKey(for: filter) else {
+ return false
+ }
+ return libraryFilterValuesByCacheKey[cacheKey] != nil
+ }
+
+ func filterValuesErrorMessage(for filter: PlexLibraryFilterDefinition) -> String? {
+ guard let cacheKey = currentFilterValueCacheKey(for: filter) else {
+ return nil
+ }
+ return libraryFilterValueErrorMessages[cacheKey]
+ }
+
+ func loadFilterValues(
+ for filter: PlexLibraryFilterDefinition,
+ forceRefresh: Bool = false
+ ) async {
+ guard filter.valuesPath != nil,
+ let initialCacheKey = currentFilterValueCacheKey(for: filter),
+ !loadingLibraryFilterValueKeys.contains(initialCacheKey) else {
+ return
+ }
+ if !forceRefresh, libraryFilterValuesByCacheKey[initialCacheKey] != nil {
+ return
+ }
+
+ loadingLibraryFilterValueKeys.insert(initialCacheKey)
+ defer { loadingLibraryFilterValueKeys.remove(initialCacheKey) }
+
+ do {
+ let result = try await connectionStore.perform { configuration in
+ let values = try await self.client.fetchLibraryFilterValues(
+ for: filter,
+ using: configuration
+ )
+ let cacheKey = Self.filterValueCacheKey(
+ filter: filter,
+ serverIdentifier: configuration.serverIdentifier,
+ serverURL: configuration.serverURL,
+ authenticationCacheScope: configuration.authenticationCacheScope
+ )
+ return (cacheKey, values)
+ }
+ libraryFilterValuesByCacheKey[result.0] = result.1
+ libraryFilterValueErrorMessages[result.0] = nil
+ } catch is CancellationError {
+ return
+ } catch {
+ guard !Task.isCancelled else {
+ return
+ }
+ libraryFilterValueErrorMessages[initialCacheKey] = error.localizedDescription
+ }
+ }
+}
+
+private extension PlexBrowserStore {
+ func currentFilterValueCacheKey(for filter: PlexLibraryFilterDefinition) -> String? {
+ guard let serverURL = connectionStore.resolvedServerURL else {
+ return nil
+ }
+ return Self.filterValueCacheKey(
+ filter: filter,
+ serverIdentifier: connectionStore.activeConnection?.serverID
+ ?? connectionStore.settings.selectedServerIdentifier,
+ serverURL: serverURL,
+ authenticationCacheScope: PlexConnectionConfiguration.authenticationCacheScope(
+ for: connectionStore.settings.trimmedServerToken
+ )
+ )
+ }
+
+ static func filterValueCacheKey(
+ filter: PlexLibraryFilterDefinition,
+ serverIdentifier: String?,
+ serverURL: URL,
+ authenticationCacheScope: String
+ ) -> String {
+ [
+ serverIdentifier ?? "",
+ serverURL.absoluteString,
+ authenticationCacheScope,
+ filter.valuesPath ?? "",
+ ].joined(separator: "|")
+ }
+}
diff --git a/PlexBar/Stores/PlexBrowserStore+GlobalSearch.swift b/PlexBar/Stores/PlexBrowserStore+GlobalSearch.swift
new file mode 100644
index 0000000..24b119f
--- /dev/null
+++ b/PlexBar/Stores/PlexBrowserStore+GlobalSearch.swift
@@ -0,0 +1,35 @@
+import PlexModels
+import Foundation
+
+extension PlexBrowserStore {
+ func searchAllLibraries(query: String) async throws -> [PlexHub] {
+ try await connectionStore.perform { configuration in
+ let endpoints = try await self.advertisedLibraryProviderEndpoints(
+ using: configuration
+ )
+ guard let searchPath = endpoints.searchPath else {
+ throw PlexAPIError.missingLibrarySearchFeature
+ }
+ return try await client.fetchSearchHubs(
+ query: query,
+ endpointPath: searchPath,
+ using: configuration
+ )
+ }
+ }
+
+ func globalSearchHubPage(
+ path: String,
+ start: Int,
+ size: Int
+ ) async throws -> PlexMediaPage {
+ try await connectionStore.perform { configuration in
+ try await client.fetchMediaPage(
+ contentPath: path,
+ using: configuration,
+ start: start,
+ size: size
+ )
+ }
+ }
+}
diff --git a/PlexBar/Stores/PlexBrowserStore+Home.swift b/PlexBar/Stores/PlexBrowserStore+Home.swift
new file mode 100644
index 0000000..e034344
--- /dev/null
+++ b/PlexBar/Stores/PlexBrowserStore+Home.swift
@@ -0,0 +1,178 @@
+import PlexModels
+import Foundation
+
+struct PlexHomeState {
+ var hubs: [PlexHub] = []
+ var hasLoadedHubs = false
+ var isLoadingHubs = false
+ var hubsErrorMessage: String?
+ var itemsByHubPath: [String: [PlexMediaItem]] = [:]
+ var totalSizesByHubPath: [String: Int] = [:]
+ var loadedHubPaths: Set = []
+ var loadingHubPaths: Set = []
+ var errorMessagesByHubPath: [String: String] = [:]
+}
+
+extension PlexBrowserStore {
+ var homeHubs: [PlexHub] {
+ homeState.hubs.filter { !$0.metadata.isEmpty }
+ }
+
+ var hasLoadedHomeHubs: Bool {
+ homeState.hasLoadedHubs
+ }
+
+ var isLoadingHomeHubs: Bool {
+ homeState.isLoadingHubs
+ }
+
+ var homeHubsErrorMessage: String? {
+ homeState.hubsErrorMessage
+ }
+
+ func loadHomeHubs(forceRefresh: Bool = false) async {
+ guard connectionStore.settings.hasValidConfiguration else {
+ homeState = PlexHomeState()
+ return
+ }
+ guard !homeState.isLoadingHubs else {
+ return
+ }
+ if homeState.hasLoadedHubs, !forceRefresh {
+ return
+ }
+
+ homeState.isLoadingHubs = true
+ defer { homeState.isLoadingHubs = false }
+
+ do {
+ homeState.hubs = try await connectionStore.perform { configuration in
+ let endpoints = try await self.advertisedLibraryProviderEndpoints(
+ using: configuration
+ )
+ return try await client.fetchHomeHubs(
+ endpoints: endpoints,
+ using: configuration
+ )
+ }
+ homeState.hasLoadedHubs = true
+ homeState.hubsErrorMessage = nil
+ } catch is CancellationError {
+ return
+ } catch {
+ guard !Task.isCancelled else {
+ return
+ }
+ homeState.hubsErrorMessage = error.localizedDescription
+ }
+ }
+
+ func homeHubItems(in hub: PlexHub) -> [PlexMediaItem] {
+ guard let path = hub.key else {
+ return hub.metadata
+ }
+ return homeState.itemsByHubPath[path] ?? hub.metadata
+ }
+
+ func isLoadingHomeHubItems(in hub: PlexHub) -> Bool {
+ guard let path = hub.key else {
+ return false
+ }
+ return homeState.loadingHubPaths.contains(path)
+ }
+
+ func homeHubItemsErrorMessage(in hub: PlexHub) -> String? {
+ guard let path = hub.key else {
+ return nil
+ }
+ return homeState.errorMessagesByHubPath[path]
+ }
+
+ func hasMoreHomeHubItems(in hub: PlexHub) -> Bool {
+ guard let path = hub.key else {
+ return false
+ }
+ if !homeState.loadedHubPaths.contains(path) {
+ return hub.more
+ }
+ let items = homeState.itemsByHubPath[path] ?? []
+ let totalSize = homeState.totalSizesByHubPath[path] ?? items.count
+ return items.count < totalSize
+ }
+
+ func loadHomeHubItems(in hub: PlexHub, forceRefresh: Bool = false) async {
+ guard let path = hub.key,
+ !homeState.loadingHubPaths.contains(path) else {
+ return
+ }
+ if homeState.loadedHubPaths.contains(path), !forceRefresh {
+ return
+ }
+
+ homeState.loadingHubPaths.insert(path)
+ defer { homeState.loadingHubPaths.remove(path) }
+
+ do {
+ let page = try await fetchHomeHubPage(path: path, start: 0)
+ homeState.itemsByHubPath[path] = page.items
+ homeState.totalSizesByHubPath[path] = page.totalSize ?? hub.totalSize ?? page.items.count
+ homeState.loadedHubPaths.insert(path)
+ homeState.errorMessagesByHubPath[path] = nil
+ } catch is CancellationError {
+ return
+ } catch {
+ guard !Task.isCancelled else {
+ return
+ }
+ homeState.errorMessagesByHubPath[path] = error.localizedDescription
+ }
+ }
+
+ func loadMoreHomeHubItemsIfNeeded(
+ in hub: PlexHub,
+ currentItem: PlexMediaItem
+ ) async {
+ guard let path = hub.key,
+ homeState.itemsByHubPath[path]?.last?.id == currentItem.id,
+ hasMoreHomeHubItems(in: hub),
+ !homeState.loadingHubPaths.contains(path) else {
+ return
+ }
+
+ homeState.loadingHubPaths.insert(path)
+ defer { homeState.loadingHubPaths.remove(path) }
+
+ do {
+ let existingItems = homeState.itemsByHubPath[path] ?? []
+ let page = try await fetchHomeHubPage(path: path, start: existingItems.count)
+ let existingIDs = Set(existingItems.map(\.id))
+ homeState.itemsByHubPath[path] = existingItems
+ + page.items.filter { !existingIDs.contains($0.id) }
+ homeState.totalSizesByHubPath[path] = page.totalSize
+ ?? homeState.totalSizesByHubPath[path]
+ ?? hub.totalSize
+ ?? page.items.count
+ homeState.errorMessagesByHubPath[path] = nil
+ } catch is CancellationError {
+ return
+ } catch {
+ guard !Task.isCancelled else {
+ return
+ }
+ homeState.errorMessagesByHubPath[path] = error.localizedDescription
+ }
+ }
+}
+
+private extension PlexBrowserStore {
+ func fetchHomeHubPage(path: String, start: Int) async throws -> PlexMediaPage {
+ try await connectionStore.perform { configuration in
+ try await client.fetchMediaPage(
+ contentPath: path,
+ using: configuration,
+ start: start,
+ size: pageSize
+ )
+ }
+ }
+}
diff --git a/PlexBar/Stores/PlexBrowserStore+MediaExtras.swift b/PlexBar/Stores/PlexBrowserStore+MediaExtras.swift
new file mode 100644
index 0000000..08285cd
--- /dev/null
+++ b/PlexBar/Stores/PlexBrowserStore+MediaExtras.swift
@@ -0,0 +1,139 @@
+import PlexModels
+import Foundation
+
+struct PlexMediaExtrasState {
+ var itemsByRatingKey: [String: [PlexMediaItem]] = [:]
+ var loadedRatingKeys: Set = []
+ var loadingRatingKeys: Set = []
+ var errorMessagesByRatingKey: [String: String] = [:]
+ var recency: [String] = []
+}
+
+extension PlexBrowserStore {
+ func mediaExtras(for item: PlexMediaItem) -> [PlexMediaItem] {
+ mediaExtrasState.itemsByRatingKey[item.ratingKey] ?? []
+ }
+
+ func hasLoadedMediaExtras(for item: PlexMediaItem) -> Bool {
+ mediaExtrasState.loadedRatingKeys.contains(item.ratingKey)
+ }
+
+ func isLoadingMediaExtras(for item: PlexMediaItem) -> Bool {
+ mediaExtrasState.loadingRatingKeys.contains(item.ratingKey)
+ }
+
+ func mediaExtrasErrorMessage(for item: PlexMediaItem) -> String? {
+ mediaExtrasState.errorMessagesByRatingKey[item.ratingKey]
+ }
+
+ func replaceCachedMediaExtrasWatchedState(with refreshedItem: PlexMediaItem) {
+ mediaExtrasState.itemsByRatingKey = mediaExtrasState.itemsByRatingKey.mapValues { items in
+ items.map { item in
+ item.mergingWatchedState(from: refreshedItem)
+ }
+ }
+ }
+
+ func replaceCachedMediaExtrasUserRating(with refreshedItem: PlexMediaItem) {
+ mediaExtrasState.itemsByRatingKey = mediaExtrasState.itemsByRatingKey.mapValues { items in
+ items.map { item in
+ item.mergingUserRating(from: refreshedItem)
+ }
+ }
+ }
+
+ func resetMediaExtras() {
+ mediaExtrasGenerationsByRatingKey.removeAll()
+ mediaExtrasState = PlexMediaExtrasState()
+ }
+
+ func loadMediaExtras(
+ for item: PlexMediaItem,
+ forceRefresh: Bool = false
+ ) async {
+ await loadMediaExtras(
+ for: item,
+ forceRefresh: forceRefresh,
+ load: fetchMediaExtras
+ )
+ }
+
+ func loadMediaExtras(
+ for item: PlexMediaItem,
+ forceRefresh: Bool = false,
+ load: (String) async throws -> [PlexMediaItem]
+ ) async {
+ guard item.supportsMediaExtras else { return }
+ guard connectionStore.settings.hasValidConfiguration else {
+ resetMediaExtras()
+ return
+ }
+ guard !mediaExtrasState.loadingRatingKeys.contains(item.ratingKey) else {
+ return
+ }
+ if mediaExtrasState.loadedRatingKeys.contains(item.ratingKey), !forceRefresh {
+ recordMediaExtrasAccess(item.ratingKey)
+ return
+ }
+
+ let generation = mediaExtrasGeneration(for: item.ratingKey)
+ mediaExtrasState.loadingRatingKeys.insert(item.ratingKey)
+ defer {
+ if mediaExtrasGenerationsByRatingKey[item.ratingKey] == generation {
+ mediaExtrasState.loadingRatingKeys.remove(item.ratingKey)
+ }
+ }
+
+ do {
+ let extras = try await load(item.ratingKey)
+ guard mediaExtrasGenerationsByRatingKey[item.ratingKey] == generation else {
+ return
+ }
+ mediaExtrasState.itemsByRatingKey[item.ratingKey] = extras
+ mediaExtrasState.loadedRatingKeys.insert(item.ratingKey)
+ mediaExtrasState.errorMessagesByRatingKey[item.ratingKey] = nil
+ recordMediaExtrasAccess(item.ratingKey)
+ } catch {
+ guard mediaExtrasGenerationsByRatingKey[item.ratingKey] == generation,
+ !Task.isCancelled else {
+ return
+ }
+ mediaExtrasState.errorMessagesByRatingKey[item.ratingKey] = error.localizedDescription
+ recordMediaExtrasAccess(item.ratingKey)
+ }
+ }
+}
+
+private extension PlexBrowserStore {
+ func mediaExtrasGeneration(for ratingKey: String) -> UUID {
+ if let generation = mediaExtrasGenerationsByRatingKey[ratingKey] {
+ return generation
+ }
+ let generation = UUID()
+ mediaExtrasGenerationsByRatingKey[ratingKey] = generation
+ return generation
+ }
+
+ func fetchMediaExtras(ratingKey: String) async throws -> [PlexMediaItem] {
+ try await connectionStore.perform { configuration in
+ try await client.fetchMediaExtras(
+ ratingKey: ratingKey,
+ using: configuration
+ )
+ }
+ }
+
+ func recordMediaExtrasAccess(_ ratingKey: String) {
+ mediaExtrasState.recency.removeAll { $0 == ratingKey }
+ mediaExtrasState.recency.append(ratingKey)
+
+ while mediaExtrasState.recency.count > mediaExtrasLimit {
+ let evictedRatingKey = mediaExtrasState.recency.removeFirst()
+ mediaExtrasState.itemsByRatingKey[evictedRatingKey] = nil
+ mediaExtrasState.loadedRatingKeys.remove(evictedRatingKey)
+ mediaExtrasState.loadingRatingKeys.remove(evictedRatingKey)
+ mediaExtrasState.errorMessagesByRatingKey[evictedRatingKey] = nil
+ mediaExtrasGenerationsByRatingKey[evictedRatingKey] = nil
+ }
+ }
+}
diff --git a/PlexBar/Stores/PlexBrowserStore+People.swift b/PlexBar/Stores/PlexBrowserStore+People.swift
new file mode 100644
index 0000000..32c36c6
--- /dev/null
+++ b/PlexBar/Stores/PlexBrowserStore+People.swift
@@ -0,0 +1,127 @@
+import PlexModels
+import Foundation
+
+struct PlexPeopleState {
+ var peopleByIdentifier: [String: PlexTag] = [:]
+ var mediaByIdentifier: [String: [PlexMediaItem]] = [:]
+ var loadedIdentifiers: Set = []
+ var loadingIdentifiers: Set = []
+ var errorMessagesByIdentifier: [String: String] = [:]
+ var recency: [String] = []
+}
+
+extension PlexBrowserStore {
+ func episodeSeriesCast(for item: PlexMediaItem) async -> [PlexTag] {
+ do {
+ return try await connectionStore.perform { configuration in
+ try await self.client.fetchEpisodeSeriesCast(
+ for: item,
+ using: configuration
+ )
+ }
+ } catch {
+ return []
+ }
+ }
+
+ func person(for route: PlexPersonRoute) -> PlexTag? {
+ peopleState.peopleByIdentifier[route.identifier]
+ }
+
+ func personMedia(for route: PlexPersonRoute) -> [PlexMediaItem] {
+ peopleState.mediaByIdentifier[route.identifier] ?? []
+ }
+
+ func isLoadingPerson(_ route: PlexPersonRoute) -> Bool {
+ peopleState.loadingIdentifiers.contains(route.identifier)
+ }
+
+ func personErrorMessage(for route: PlexPersonRoute) -> String? {
+ peopleState.errorMessagesByIdentifier[route.identifier]
+ }
+
+ func resetPeople() {
+ peopleGenerationsByIdentifier.removeAll()
+ peopleState = PlexPeopleState()
+ }
+
+ func loadPerson(
+ _ route: PlexPersonRoute,
+ forceRefresh: Bool = false
+ ) async {
+ guard connectionStore.settings.hasValidConfiguration else {
+ resetPeople()
+ return
+ }
+ guard !peopleState.loadingIdentifiers.contains(route.identifier) else {
+ return
+ }
+ if peopleState.loadedIdentifiers.contains(route.identifier), !forceRefresh {
+ recordPeopleAccess(route.identifier)
+ return
+ }
+
+ let generation = peopleGeneration(for: route.identifier)
+ peopleState.loadingIdentifiers.insert(route.identifier)
+ defer {
+ if peopleGenerationsByIdentifier[route.identifier] == generation {
+ peopleState.loadingIdentifiers.remove(route.identifier)
+ }
+ }
+
+ do {
+ let result = try await connectionStore.perform { configuration in
+ async let person = self.client.fetchPerson(
+ identifier: route.identifier,
+ using: configuration
+ )
+ async let media = self.client.fetchPersonMedia(
+ identifier: route.identifier,
+ using: configuration
+ )
+ return try await (person, media)
+ }
+ guard peopleGenerationsByIdentifier[route.identifier] == generation else {
+ return
+ }
+ peopleState.peopleByIdentifier[route.identifier] = result.0
+ peopleState.mediaByIdentifier[route.identifier] = result.1
+ peopleState.loadedIdentifiers.insert(route.identifier)
+ peopleState.errorMessagesByIdentifier[route.identifier] = nil
+ recordPeopleAccess(route.identifier)
+ } catch {
+ guard peopleGenerationsByIdentifier[route.identifier] == generation,
+ !Task.isCancelled else {
+ return
+ }
+ peopleState.errorMessagesByIdentifier[route.identifier] = error.localizedDescription
+ recordPeopleAccess(route.identifier)
+ }
+ }
+}
+
+private extension PlexBrowserStore {
+ func peopleGeneration(for identifier: String) -> UUID {
+ if let generation = peopleGenerationsByIdentifier[identifier] {
+ return generation
+ }
+ let generation = UUID()
+ peopleGenerationsByIdentifier[identifier] = generation
+ return generation
+ }
+
+ func recordPeopleAccess(_ identifier: String) {
+ peopleState.recency.removeAll { $0 == identifier }
+ peopleState.recency.append(identifier)
+
+ while peopleState.recency.count > peopleLimit {
+ let evictedIdentifier = peopleState.recency.removeFirst()
+ peopleState.peopleByIdentifier[evictedIdentifier] = nil
+ peopleState.mediaByIdentifier[evictedIdentifier] = nil
+ peopleState.loadedIdentifiers.remove(evictedIdentifier)
+ peopleState.loadingIdentifiers.remove(evictedIdentifier)
+ peopleState.errorMessagesByIdentifier[evictedIdentifier] = nil
+ peopleGenerationsByIdentifier[evictedIdentifier] = nil
+ }
+ }
+}
diff --git a/PlexBar/Stores/PlexBrowserStore+RelatedContent.swift b/PlexBar/Stores/PlexBrowserStore+RelatedContent.swift
new file mode 100644
index 0000000..57e4935
--- /dev/null
+++ b/PlexBar/Stores/PlexBrowserStore+RelatedContent.swift
@@ -0,0 +1,339 @@
+import PlexModels
+import Foundation
+
+struct PlexRelatedContentState {
+ var hubsByRatingKey: [String: [PlexHub]] = [:]
+ var loadedRatingKeys: Set = []
+ var loadingRatingKeys: Set = []
+ var errorMessagesByRatingKey: [String: String] = [:]
+ var recency: [String] = []
+ var itemsByHubRoute: [PlexRelatedHubRoute: [PlexMediaItem]] = [:]
+ var totalSizesByHubRoute: [PlexRelatedHubRoute: Int] = [:]
+ var loadedHubRoutes: Set = []
+ var loadingHubRoutes: Set = []
+ var errorMessagesByHubRoute: [PlexRelatedHubRoute: String] = [:]
+}
+
+extension PlexBrowserStore {
+ func relatedHubs(for item: PlexMediaItem) -> [PlexHub] {
+ relatedContentState.hubsByRatingKey[item.ratingKey] ?? []
+ }
+
+ func hasLoadedRelatedContent(for item: PlexMediaItem) -> Bool {
+ relatedContentState.loadedRatingKeys.contains(item.ratingKey)
+ }
+
+ func isLoadingRelatedContent(for item: PlexMediaItem) -> Bool {
+ relatedContentState.loadingRatingKeys.contains(item.ratingKey)
+ }
+
+ func relatedContentErrorMessage(for item: PlexMediaItem) -> String? {
+ relatedContentState.errorMessagesByRatingKey[item.ratingKey]
+ }
+
+ func relatedHub(for route: PlexRelatedHubRoute) -> PlexHub? {
+ relatedContentState.hubsByRatingKey[route.sourceRatingKey]?
+ .first { route.matches(sourceRatingKey: route.sourceRatingKey, hub: $0) }
+ }
+
+ func relatedHubItems(for route: PlexRelatedHubRoute) -> [PlexMediaItem] {
+ guard let hub = relatedHub(for: route) else {
+ return []
+ }
+ return relatedContentState.itemsByHubRoute[route] ?? hub.metadata
+ }
+
+ func isLoadingRelatedHubItems(for route: PlexRelatedHubRoute) -> Bool {
+ relatedContentState.loadingHubRoutes.contains(route)
+ }
+
+ func relatedHubItemsErrorMessage(for route: PlexRelatedHubRoute) -> String? {
+ relatedContentState.errorMessagesByHubRoute[route]
+ }
+
+ func hasMoreRelatedHubItems(for route: PlexRelatedHubRoute) -> Bool {
+ guard let hub = relatedHub(for: route) else {
+ return false
+ }
+ if !relatedContentState.loadedHubRoutes.contains(route) {
+ let advertisedTotal = hub.totalSize ?? hub.size ?? hub.metadata.count
+ return hub.more || advertisedTotal > hub.metadata.count
+ }
+ let items = relatedContentState.itemsByHubRoute[route] ?? []
+ let totalSize = relatedContentState.totalSizesByHubRoute[route] ?? items.count
+ return items.count < totalSize
+ }
+
+ func replaceCachedRelatedWatchedState(with refreshedItem: PlexMediaItem) {
+ relatedContentState.hubsByRatingKey = relatedContentState.hubsByRatingKey.mapValues { hubs in
+ hubs.map { hub in
+ var updatedHub = hub
+ updatedHub.metadata = hub.metadata.map { item in
+ item.mergingWatchedState(from: refreshedItem)
+ }
+ return updatedHub
+ }
+ }
+ relatedContentState.itemsByHubRoute = relatedContentState.itemsByHubRoute.mapValues {
+ $0.map { item in
+ item.mergingWatchedState(from: refreshedItem)
+ }
+ }
+ }
+
+ func replaceCachedRelatedUserRating(with refreshedItem: PlexMediaItem) {
+ relatedContentState.hubsByRatingKey = relatedContentState.hubsByRatingKey.mapValues { hubs in
+ hubs.map { hub in
+ var updatedHub = hub
+ updatedHub.metadata = hub.metadata.map { item in
+ item.mergingUserRating(from: refreshedItem)
+ }
+ return updatedHub
+ }
+ }
+ relatedContentState.itemsByHubRoute = relatedContentState.itemsByHubRoute.mapValues {
+ $0.map { item in
+ item.mergingUserRating(from: refreshedItem)
+ }
+ }
+ }
+
+ func resetRelatedContent() {
+ relatedContentGenerationsByRatingKey.removeAll()
+ relatedContentState = PlexRelatedContentState()
+ }
+
+ func loadRelatedContent(
+ for item: PlexMediaItem,
+ forceRefresh: Bool = false
+ ) async {
+ guard connectionStore.settings.hasValidConfiguration else {
+ resetRelatedContent()
+ return
+ }
+ guard !relatedContentState.loadingRatingKeys.contains(item.ratingKey) else {
+ return
+ }
+ if relatedContentState.loadedRatingKeys.contains(item.ratingKey), !forceRefresh {
+ recordRelatedContentAccess(item.ratingKey)
+ return
+ }
+
+ let generation = relatedContentGeneration(for: item.ratingKey)
+ relatedContentState.loadingRatingKeys.insert(item.ratingKey)
+ defer {
+ if relatedContentGenerationsByRatingKey[item.ratingKey] == generation {
+ relatedContentState.loadingRatingKeys.remove(item.ratingKey)
+ }
+ }
+
+ do {
+ let hubs = try await connectionStore.perform { configuration in
+ try await client.fetchRelatedHubs(
+ ratingKey: item.ratingKey,
+ using: configuration
+ )
+ }
+ guard relatedContentGenerationsByRatingKey[item.ratingKey] == generation else {
+ return
+ }
+
+ relatedContentState.loadingRatingKeys.remove(item.ratingKey)
+ relatedContentGenerationsByRatingKey[item.ratingKey] = UUID()
+ relatedContentState.loadingHubRoutes.subtract(
+ relatedContentState.loadingHubRoutes.filter {
+ $0.sourceRatingKey == item.ratingKey
+ }
+ )
+ removeStaleRelatedHubPages(for: item.ratingKey, keeping: hubs)
+ relatedContentState.hubsByRatingKey[item.ratingKey] = hubs
+ relatedContentState.loadedRatingKeys.insert(item.ratingKey)
+ relatedContentState.errorMessagesByRatingKey[item.ratingKey] = nil
+ recordRelatedContentAccess(item.ratingKey)
+ } catch {
+ guard relatedContentGenerationsByRatingKey[item.ratingKey] == generation,
+ !Task.isCancelled else {
+ return
+ }
+ relatedContentState.errorMessagesByRatingKey[item.ratingKey] = error.localizedDescription
+ recordRelatedContentAccess(item.ratingKey)
+ }
+ }
+
+ func loadRelatedHubItems(
+ for route: PlexRelatedHubRoute,
+ forceRefresh: Bool = false
+ ) async {
+ await loadRelatedHubItems(
+ for: route,
+ forceRefresh: forceRefresh,
+ loadPage: fetchRelatedHubPage
+ )
+ }
+
+ func loadRelatedHubItems(
+ for route: PlexRelatedHubRoute,
+ forceRefresh: Bool = false,
+ loadPage: (String, Int) async throws -> PlexMediaPage
+ ) async {
+ guard let hub = relatedHub(for: route),
+ !relatedContentState.loadingHubRoutes.contains(route),
+ let generation = relatedContentGenerationsByRatingKey[route.sourceRatingKey] else {
+ return
+ }
+ if relatedContentState.loadedHubRoutes.contains(route), !forceRefresh {
+ recordRelatedContentAccess(route.sourceRatingKey)
+ return
+ }
+
+ relatedContentState.loadingHubRoutes.insert(route)
+ defer {
+ if relatedContentGenerationsByRatingKey[route.sourceRatingKey] == generation {
+ relatedContentState.loadingHubRoutes.remove(route)
+ }
+ }
+
+ do {
+ let page = try await loadPage(route.hubKey, 0)
+ guard relatedContentGenerationsByRatingKey[route.sourceRatingKey] == generation,
+ relatedHub(for: route) != nil else {
+ return
+ }
+ relatedContentState.itemsByHubRoute[route] = page.items
+ relatedContentState.totalSizesByHubRoute[route] = page.totalSize
+ ?? hub.totalSize
+ ?? page.items.count
+ relatedContentState.loadedHubRoutes.insert(route)
+ relatedContentState.errorMessagesByHubRoute[route] = nil
+ recordRelatedContentAccess(route.sourceRatingKey)
+ } catch {
+ guard relatedContentGenerationsByRatingKey[route.sourceRatingKey] == generation,
+ !Task.isCancelled else {
+ return
+ }
+ relatedContentState.errorMessagesByHubRoute[route] = error.localizedDescription
+ recordRelatedContentAccess(route.sourceRatingKey)
+ }
+ }
+
+ func loadMoreRelatedHubItemsIfNeeded(
+ for route: PlexRelatedHubRoute,
+ currentItem: PlexMediaItem
+ ) async {
+ guard let hub = relatedHub(for: route),
+ relatedContentState.itemsByHubRoute[route]?.last?.id == currentItem.id,
+ hasMoreRelatedHubItems(for: route),
+ !relatedContentState.loadingHubRoutes.contains(route),
+ let generation = relatedContentGenerationsByRatingKey[route.sourceRatingKey] else {
+ return
+ }
+
+ relatedContentState.loadingHubRoutes.insert(route)
+ defer {
+ if relatedContentGenerationsByRatingKey[route.sourceRatingKey] == generation {
+ relatedContentState.loadingHubRoutes.remove(route)
+ }
+ }
+
+ do {
+ let existingItems = relatedContentState.itemsByHubRoute[route] ?? []
+ let page = try await fetchRelatedHubPage(
+ path: route.hubKey,
+ start: existingItems.count
+ )
+ guard relatedContentGenerationsByRatingKey[route.sourceRatingKey] == generation,
+ relatedHub(for: route) != nil else {
+ return
+ }
+ let existingIDs = Set(existingItems.map(\.id))
+ relatedContentState.itemsByHubRoute[route] = existingItems
+ + page.items.filter { !existingIDs.contains($0.id) }
+ relatedContentState.totalSizesByHubRoute[route] = page.totalSize
+ ?? relatedContentState.totalSizesByHubRoute[route]
+ ?? hub.totalSize
+ ?? page.items.count
+ relatedContentState.errorMessagesByHubRoute[route] = nil
+ recordRelatedContentAccess(route.sourceRatingKey)
+ } catch {
+ guard relatedContentGenerationsByRatingKey[route.sourceRatingKey] == generation,
+ !Task.isCancelled else {
+ return
+ }
+ relatedContentState.errorMessagesByHubRoute[route] = error.localizedDescription
+ recordRelatedContentAccess(route.sourceRatingKey)
+ }
+ }
+}
+
+private extension PlexBrowserStore {
+ func relatedContentGeneration(for ratingKey: String) -> UUID {
+ if let generation = relatedContentGenerationsByRatingKey[ratingKey] {
+ return generation
+ }
+ let generation = UUID()
+ relatedContentGenerationsByRatingKey[ratingKey] = generation
+ return generation
+ }
+
+ func fetchRelatedHubPage(path: String, start: Int) async throws -> PlexMediaPage {
+ try await connectionStore.perform { configuration in
+ try await client.fetchMediaPage(
+ contentPath: path,
+ using: configuration,
+ start: start,
+ size: pageSize
+ )
+ }
+ }
+
+ func removeStaleRelatedHubPages(for ratingKey: String, keeping hubs: [PlexHub]) {
+ let retainedRoutes = Set(hubs.compactMap { hub in
+ PlexRelatedHubRoute(
+ sourceRatingKey: ratingKey,
+ hub: hub
+ )
+ })
+ let staleRoutes = relatedHubRoutes(for: ratingKey).filter {
+ $0.sourceRatingKey == ratingKey && !retainedRoutes.contains($0)
+ }
+ for route in staleRoutes {
+ removeRelatedHubPage(for: route)
+ }
+ }
+
+ func removeRelatedHubPage(for route: PlexRelatedHubRoute) {
+ relatedContentState.itemsByHubRoute[route] = nil
+ relatedContentState.totalSizesByHubRoute[route] = nil
+ relatedContentState.loadedHubRoutes.remove(route)
+ relatedContentState.loadingHubRoutes.remove(route)
+ relatedContentState.errorMessagesByHubRoute[route] = nil
+ }
+
+ func relatedHubRoutes(for ratingKey: String) -> Set {
+ Set(relatedContentState.itemsByHubRoute.keys)
+ .union(relatedContentState.totalSizesByHubRoute.keys)
+ .union(relatedContentState.loadedHubRoutes)
+ .union(relatedContentState.loadingHubRoutes)
+ .union(relatedContentState.errorMessagesByHubRoute.keys)
+ .filter { $0.sourceRatingKey == ratingKey }
+ }
+
+ func recordRelatedContentAccess(_ ratingKey: String) {
+ relatedContentState.recency.removeAll { $0 == ratingKey }
+ relatedContentState.recency.append(ratingKey)
+
+ while relatedContentState.recency.count > relatedContentLimit {
+ let evictedRatingKey = relatedContentState.recency.removeFirst()
+ relatedContentState.hubsByRatingKey[evictedRatingKey] = nil
+ relatedContentState.loadedRatingKeys.remove(evictedRatingKey)
+ relatedContentState.loadingRatingKeys.remove(evictedRatingKey)
+ relatedContentState.errorMessagesByRatingKey[evictedRatingKey] = nil
+ relatedContentGenerationsByRatingKey[evictedRatingKey] = nil
+
+ let evictedRoutes = relatedHubRoutes(for: evictedRatingKey)
+ for route in evictedRoutes {
+ removeRelatedHubPage(for: route)
+ }
+ }
+ }
+}
diff --git a/PlexBar/Stores/PlexBrowserStore.swift b/PlexBar/Stores/PlexBrowserStore.swift
new file mode 100644
index 0000000..6c238de
--- /dev/null
+++ b/PlexBar/Stores/PlexBrowserStore.swift
@@ -0,0 +1,1737 @@
+import PlexModels
+import Foundation
+import Observation
+
+@MainActor
+@Observable
+final class PlexBrowserStore {
+ let connectionStore: PlexConnectionStore
+ let client: PlexAPIClient
+ private let playbackCapabilities: PlexPlaybackCapabilities
+ let pageSize: Int
+ let transientLibraryRequestLimit: Int
+ let relatedContentLimit: Int
+ let mediaExtrasLimit: Int
+ let peopleLimit: Int
+ let globalSearchStore: PlexGlobalSearchStore
+
+ var homeState = PlexHomeState()
+ var relatedContentState = PlexRelatedContentState()
+ var relatedContentGenerationsByRatingKey: [String: UUID] = [:]
+ var mediaExtrasState = PlexMediaExtrasState()
+ var mediaExtrasGenerationsByRatingKey: [String: UUID] = [:]
+ var peopleState = PlexPeopleState()
+ var peopleGenerationsByIdentifier: [String: UUID] = [:]
+ private var libraryItems: [PlexLibraryRequest: [PlexMediaItem]] = [:]
+ private var libraryTotalSizes: [PlexLibraryRequest: Int] = [:]
+ private var loadingLibraries: Set = []
+ private var libraryErrorMessages: [PlexLibraryRequest: String] = [:]
+ private var transientLibraryRequestRecency: [String: [PlexLibraryRequest]] = [:]
+ private var libraryBrowseDefinitions: [String: PlexLibraryBrowseDefinition] = [:]
+ private var libraryBrowseDefinitionTasks: [String: Task] = [:]
+ var collectionItemsByLibraryID: [String: [PlexMediaItem]] = [:]
+ var collectionTotalSizesByLibraryID: [String: Int] = [:]
+ var loadingCollectionLibraryIDs: Set = []
+ var collectionErrorMessagesByLibraryID: [String: String] = [:]
+ var playlists: [PlexMediaItem] = []
+ var playlistsTotalSize: Int?
+ var isLoadingPlaylists = false
+ var playlistsErrorMessage: String?
+ private var childItems: [String: [PlexMediaItem]] = [:]
+ private var childTotalSizes: [String: Int] = [:]
+ private var loadingChildPaths: Set = []
+ private var childErrorMessages: [String: String] = [:]
+ private var resolvedMediaItemsByRoute: [PlexMediaRoute: PlexMediaItem] = [:]
+ var libraryFilterValuesByCacheKey: [String: [PlexLibraryFilterValue]] = [:]
+ var loadingLibraryFilterValueKeys: Set = []
+ var libraryFilterValueErrorMessages: [String: String] = [:]
+ private var providerEndpointsByConnectionKey: [String: PlexLibraryProviderEndpoints] = [:]
+ private var providerEndpointTasksByConnectionKey: [String: Task] = [:]
+ private(set) var presentedProviderEndpoints: PlexLibraryProviderEndpoints?
+ private var presentedProviderConnectionKey: String?
+ private(set) var watchedStateMutationRatingKeys: Set = []
+ private(set) var personalRatingMutationRatingKeys: Set = []
+ private(set) var metadataRefreshRatingKeys: Set = []
+ private(set) var continueWatchingRemovalRatingKeys: Set = []
+ private(set) var collectionPlaylistMutationKeys: Set = []
+
+ init(
+ connectionStore: PlexConnectionStore,
+ client: PlexAPIClient = PlexAPIClient(),
+ playbackCapabilities: PlexPlaybackCapabilities = NativePlaybackCapabilityProbe.current(),
+ pageSize: Int = 100,
+ transientLibraryRequestLimit: Int = 8,
+ relatedContentLimit: Int = 12,
+ mediaExtrasLimit: Int = 12,
+ peopleLimit: Int = 48,
+ globalSearchDebounceDuration: Duration = .milliseconds(300)
+ ) {
+ self.connectionStore = connectionStore
+ self.client = client
+ self.playbackCapabilities = playbackCapabilities
+ self.pageSize = max(pageSize, 1)
+ self.transientLibraryRequestLimit = max(transientLibraryRequestLimit, 1)
+ self.relatedContentLimit = max(relatedContentLimit, 1)
+ self.mediaExtrasLimit = max(mediaExtrasLimit, 1)
+ self.peopleLimit = max(peopleLimit, 1)
+ globalSearchStore = PlexGlobalSearchStore(
+ debounceDuration: globalSearchDebounceDuration,
+ pageSize: self.pageSize
+ )
+ }
+}
+
+extension PlexBrowserStore {
+ func resetServerScopedState() {
+ libraryBrowseDefinitionTasks.values.forEach { $0.cancel() }
+ providerEndpointTasksByConnectionKey.values.forEach { $0.cancel() }
+
+ homeState = PlexHomeState()
+ resetDetailDiscoveryContent()
+ globalSearchStore.reset()
+
+ libraryItems.removeAll()
+ libraryTotalSizes.removeAll()
+ loadingLibraries.removeAll()
+ libraryErrorMessages.removeAll()
+ transientLibraryRequestRecency.removeAll()
+ libraryBrowseDefinitions.removeAll()
+ libraryBrowseDefinitionTasks.removeAll()
+
+ collectionItemsByLibraryID.removeAll()
+ collectionTotalSizesByLibraryID.removeAll()
+ loadingCollectionLibraryIDs.removeAll()
+ collectionErrorMessagesByLibraryID.removeAll()
+
+ playlists.removeAll()
+ playlistsTotalSize = nil
+ isLoadingPlaylists = false
+ playlistsErrorMessage = nil
+
+ childItems.removeAll()
+ childTotalSizes.removeAll()
+ loadingChildPaths.removeAll()
+ childErrorMessages.removeAll()
+ resolvedMediaItemsByRoute.removeAll()
+
+ libraryFilterValuesByCacheKey.removeAll()
+ loadingLibraryFilterValueKeys.removeAll()
+ libraryFilterValueErrorMessages.removeAll()
+
+ providerEndpointsByConnectionKey.removeAll()
+ providerEndpointTasksByConnectionKey.removeAll()
+ presentedProviderEndpoints = nil
+ presentedProviderConnectionKey = nil
+
+ watchedStateMutationRatingKeys.removeAll()
+ personalRatingMutationRatingKeys.removeAll()
+ metadataRefreshRatingKeys.removeAll()
+ continueWatchingRemovalRatingKeys.removeAll()
+ collectionPlaylistMutationKeys.removeAll()
+ }
+
+ func homeHub(for route: PlexHomeHubRoute) -> PlexHub? {
+ homeState.hubs.first(where: route.matches)
+ }
+
+ func item(for route: PlexMediaRoute) -> PlexMediaItem? {
+ if let resolvedItem = resolvedMediaItemsByRoute[route] {
+ return resolvedItem
+ }
+
+ let libraryItem = libraryItems.values
+ .lazy
+ .flatMap { $0 }
+ .first(where: route.matches)
+ if let libraryItem {
+ return libraryItem
+ }
+
+ let homeItem = homeState.hubs
+ .lazy
+ .flatMap(\.metadata)
+ .first(where: route.matches)
+ ?? homeState.itemsByHubPath.values
+ .lazy
+ .flatMap { $0 }
+ .first(where: route.matches)
+ if let homeItem {
+ return homeItem
+ }
+
+ let searchItem = globalSearchStore.hubs
+ .lazy
+ .flatMap(\.metadata)
+ .first(where: route.matches)
+ ?? globalSearchStore.itemsByHubPath.values
+ .lazy
+ .flatMap { $0 }
+ .first(where: route.matches)
+ if let searchItem {
+ return searchItem
+ }
+
+ let relatedItem = relatedContentState.hubsByRatingKey.values
+ .lazy
+ .flatMap { $0 }
+ .flatMap(\.metadata)
+ .first(where: route.matches)
+ ?? relatedContentState.itemsByHubRoute.values
+ .lazy
+ .flatMap { $0 }
+ .first(where: route.matches)
+ if let relatedItem {
+ return relatedItem
+ }
+
+ let extraItem = mediaExtrasState.itemsByRatingKey.values
+ .lazy
+ .flatMap { $0 }
+ .first(where: route.matches)
+ if let extraItem {
+ return extraItem
+ }
+
+ let collectionItem = collectionItemsByLibraryID.values
+ .lazy
+ .flatMap { $0 }
+ .first(where: route.matches)
+ if let collectionItem {
+ return collectionItem
+ }
+
+ if let playlistItem = playlists.first(where: route.matches) {
+ return playlistItem
+ }
+
+ return childItems.values
+ .lazy
+ .flatMap { $0 }
+ .first(where: route.matches)
+ }
+
+ func resolveItem(for route: PlexMediaRoute) async throws -> PlexMediaItem {
+ if let item = item(for: route) {
+ return item
+ }
+
+ let item = try await connectionStore.perform { configuration in
+ try await client.fetchMediaMetadata(
+ ratingKey: route.ratingKey,
+ using: configuration
+ )
+ }
+ resolvedMediaItemsByRoute[route] = item
+ return item
+ }
+
+ func resetResolvedMediaItems() {
+ resolvedMediaItemsByRoute.removeAll()
+ }
+
+ func browseDefinition(for library: PlexLibrary) -> PlexLibraryBrowseDefinition? {
+ libraryBrowseDefinitions[library.id]
+ }
+
+ func items(
+ in library: PlexLibrary,
+ searchQuery: String = "",
+ browseOptions: PlexLibraryBrowseOptions = .default
+ ) -> [PlexMediaItem] {
+ let request = PlexLibraryRequest(
+ libraryID: library.id,
+ searchQuery: searchQuery,
+ browseOptions: browseOptions
+ )
+ return libraryItems[request] ?? []
+ }
+
+ func isLoading(
+ _ library: PlexLibrary,
+ searchQuery: String = "",
+ browseOptions: PlexLibraryBrowseOptions = .default
+ ) -> Bool {
+ loadingLibraries.contains(PlexLibraryRequest(
+ libraryID: library.id,
+ searchQuery: searchQuery,
+ browseOptions: browseOptions
+ ))
+ }
+
+ func errorMessage(
+ for library: PlexLibrary,
+ searchQuery: String = "",
+ browseOptions: PlexLibraryBrowseOptions = .default
+ ) -> String? {
+ let request = PlexLibraryRequest(
+ libraryID: library.id,
+ searchQuery: searchQuery,
+ browseOptions: browseOptions
+ )
+ return libraryErrorMessages[request]
+ }
+
+ func hasMoreItems(
+ in library: PlexLibrary,
+ searchQuery: String = "",
+ browseOptions: PlexLibraryBrowseOptions = .default
+ ) -> Bool {
+ let request = PlexLibraryRequest(
+ libraryID: library.id,
+ searchQuery: searchQuery,
+ browseOptions: browseOptions
+ )
+ let itemCount = libraryItems[request]?.count ?? 0
+ let totalSize = libraryTotalSizes[request]
+ ?? (request.searchQuery.isEmpty && request.browseOptions == .default ? library.itemCount : itemCount)
+ return itemCount < totalSize
+ }
+
+ func children(of item: PlexMediaItem) -> [PlexMediaItem] {
+ guard let path = item.childrenPath else {
+ return []
+ }
+ return childItems[path] ?? []
+ }
+
+ func isLoadingChildren(of item: PlexMediaItem) -> Bool {
+ guard let path = item.childrenPath else {
+ return false
+ }
+ return loadingChildPaths.contains(path)
+ }
+
+ func childrenErrorMessage(for item: PlexMediaItem) -> String? {
+ guard let path = item.childrenPath else {
+ return nil
+ }
+ return childErrorMessages[path]
+ }
+
+ func hasMoreChildren(of item: PlexMediaItem) -> Bool {
+ guard let path = item.childrenPath else {
+ return false
+ }
+ let itemCount = childItems[path]?.count ?? 0
+ return itemCount < (childTotalSizes[path] ?? itemCount)
+ }
+
+ func load(
+ _ library: PlexLibrary,
+ searchQuery: String = "",
+ browseOptions: PlexLibraryBrowseOptions = .default,
+ forceRefresh: Bool = false
+ ) async {
+ let request = PlexLibraryRequest(
+ libraryID: library.id,
+ searchQuery: searchQuery,
+ browseOptions: browseOptions
+ )
+ guard !loadingLibraries.contains(request) else {
+ return
+ }
+
+ if !forceRefresh, libraryItems[request] != nil {
+ recordLibraryRequestAccess(request)
+ return
+ }
+
+ loadingLibraries.insert(request)
+ defer { loadingLibraries.remove(request) }
+
+ do {
+ let definition = try await libraryBrowseDefinition(for: library)
+ let page = try await fetchPage(request: request, definition: definition, start: 0)
+ storeLibraryPage(page, for: request)
+ } catch is CancellationError {
+ return
+ } catch {
+ guard !Task.isCancelled else {
+ return
+ }
+ libraryErrorMessages[request] = error.localizedDescription
+ recordLibraryRequestAccess(request)
+ }
+ }
+
+ func loadMoreIfNeeded(
+ in library: PlexLibrary,
+ searchQuery: String = "",
+ browseOptions: PlexLibraryBrowseOptions = .default,
+ currentItem: PlexMediaItem
+ ) async {
+ let request = PlexLibraryRequest(
+ libraryID: library.id,
+ searchQuery: searchQuery,
+ browseOptions: browseOptions
+ )
+ guard libraryItems[request]?.last?.id == currentItem.id,
+ hasMoreItems(
+ in: library,
+ searchQuery: searchQuery,
+ browseOptions: browseOptions
+ ),
+ !loadingLibraries.contains(request) else {
+ return
+ }
+
+ loadingLibraries.insert(request)
+ defer { loadingLibraries.remove(request) }
+
+ do {
+ let existingItems = libraryItems[request] ?? []
+ let definition = try await libraryBrowseDefinition(for: library)
+ let page = try await fetchPage(
+ request: request,
+ definition: definition,
+ start: existingItems.count
+ )
+ let existingIDs = Set(existingItems.map(\.id))
+ libraryItems[request] = existingItems + page.items.filter { !existingIDs.contains($0.id) }
+ libraryTotalSizes[request] = page.totalSize ?? libraryTotalSizes[request]
+ libraryErrorMessages[request] = nil
+ recordLibraryRequestAccess(request)
+ } catch is CancellationError {
+ return
+ } catch {
+ guard !Task.isCancelled else {
+ return
+ }
+ libraryErrorMessages[request] = error.localizedDescription
+ recordLibraryRequestAccess(request)
+ }
+ }
+}
+
+extension PlexBrowserStore {
+ var cacheMetrics: PlexBrowserCacheMetrics {
+ let requests = Set(libraryItems.keys)
+ .union(libraryTotalSizes.keys)
+ .union(libraryErrorMessages.keys)
+ let transientRequests = requests.filter(\.isTransient)
+ return PlexBrowserCacheMetrics(
+ libraryRequestCount: requests.count,
+ transientLibraryRequestCount: transientRequests.count,
+ libraryItemOccurrenceCount: libraryItems.values.reduce(0) { $0 + $1.count },
+ uniqueLibraryItemCount: Set(
+ libraryItems.values.lazy.flatMap { $0 }.map(\.id)
+ ).count,
+ transientRequestCountsByLibraryID: Dictionary(
+ grouping: transientRequests,
+ by: \.libraryID
+ ).mapValues(\.count),
+ transientRequestLimitPerLibrary: transientLibraryRequestLimit
+ )
+ }
+}
+
+private extension PlexBrowserStore {
+ func storeLibraryPage(_ page: PlexMediaPage, for request: PlexLibraryRequest) {
+ libraryItems[request] = page.items
+ libraryTotalSizes[request] = page.totalSize ?? page.items.count
+ libraryErrorMessages[request] = nil
+ recordLibraryRequestAccess(request)
+ }
+
+ func recordLibraryRequestAccess(_ request: PlexLibraryRequest) {
+ guard request.isTransient else {
+ return
+ }
+
+ var recency = transientLibraryRequestRecency[request.libraryID] ?? []
+ recency.removeAll { $0 == request }
+ recency.append(request)
+
+ while recency.count > transientLibraryRequestLimit {
+ let evictedRequest = recency.removeFirst()
+ libraryItems[evictedRequest] = nil
+ libraryTotalSizes[evictedRequest] = nil
+ libraryErrorMessages[evictedRequest] = nil
+ }
+
+ transientLibraryRequestRecency[request.libraryID] = recency
+ }
+}
+
+extension PlexBrowserStore {
+ func loadChildren(of item: PlexMediaItem, forceRefresh: Bool = false) async {
+ guard let path = item.childrenPath,
+ !loadingChildPaths.contains(path) else {
+ return
+ }
+
+ if !forceRefresh, childItems[path] != nil {
+ return
+ }
+
+ loadingChildPaths.insert(path)
+ defer { loadingChildPaths.remove(path) }
+
+ do {
+ let page = try await fetchChildrenPage(of: item, start: 0)
+ childItems[path] = page.items
+ childTotalSizes[path] = page.totalSize ?? page.items.count
+ childErrorMessages[path] = nil
+ } catch is CancellationError {
+ return
+ } catch {
+ guard !Task.isCancelled else {
+ return
+ }
+ childErrorMessages[path] = error.localizedDescription
+ }
+ }
+
+ func loadMoreChildrenIfNeeded(
+ of parent: PlexMediaItem,
+ currentItem: PlexMediaItem
+ ) async {
+ guard let path = parent.childrenPath,
+ childItems[path]?.last?.id == currentItem.id,
+ hasMoreChildren(of: parent),
+ !loadingChildPaths.contains(path) else {
+ return
+ }
+
+ loadingChildPaths.insert(path)
+ defer { loadingChildPaths.remove(path) }
+
+ do {
+ let existingItems = childItems[path] ?? []
+ let page = try await fetchChildrenPage(of: parent, start: existingItems.count)
+ let existingIDs = Set(existingItems.map(\.id))
+ childItems[path] = existingItems + page.items.filter { !existingIDs.contains($0.id) }
+ childTotalSizes[path] = page.totalSize ?? childTotalSizes[path]
+ childErrorMessages[path] = nil
+ } catch is CancellationError {
+ return
+ } catch {
+ guard !Task.isCancelled else {
+ return
+ }
+ childErrorMessages[path] = error.localizedDescription
+ }
+ }
+
+ func details(for item: PlexMediaItem) async -> PlexMediaItem {
+ do {
+ return try await connectionStore.perform { configuration in
+ try await client.fetchMediaMetadata(
+ ratingKey: item.ratingKey,
+ using: configuration
+ )
+ }
+ } catch {
+ return item
+ }
+ }
+
+ func primaryExtra(for item: PlexMediaItem) async throws -> PlexMediaItem {
+ guard let path = item.primaryExtraKey?.nilIfBlank else {
+ throw PlexAPIError.invalidResponse
+ }
+
+ return try await connectionStore.perform { configuration in
+ try await client.fetchMediaMetadata(path: path, using: configuration)
+ }
+ }
+
+ func postPlayHubs(for item: PlexMediaItem, count: Int = 12) async throws -> [PlexHub] {
+ try await connectionStore.perform { configuration in
+ try await client.fetchPostPlayHubs(
+ ratingKey: item.ratingKey,
+ using: configuration,
+ count: count
+ )
+ }
+ }
+
+ func playbackPlan(
+ for item: PlexMediaItem,
+ source: PlexPlaybackSource? = nil,
+ videoQuality: PlexVideoQuality = .original,
+ startTimeOverride: TimeInterval? = nil,
+ forceServerMediaSelection: Bool = false
+ ) async throws -> PlexPlaybackPlan {
+ try await connectionStore.perform { configuration in
+ try await client.makePlaybackPlan(
+ for: item,
+ using: configuration,
+ capabilities: playbackCapabilities,
+ source: source,
+ videoQuality: videoQuality,
+ streamingPolicy: connectionStore.settings.playbackStreamingPolicy,
+ startTimeOverride: startTimeOverride,
+ forceServerMediaSelection: forceServerMediaSelection
+ )
+ }
+ }
+
+ func selectMediaStreams(
+ partID: Int,
+ audioStreamID: Int? = nil,
+ subtitleStreamID: Int? = nil
+ ) async throws {
+ try await connectionStore.perform { configuration in
+ try await client.selectMediaStreams(
+ partID: partID,
+ audioStreamID: audioStreamID,
+ subtitleStreamID: subtitleStreamID,
+ allParts: true,
+ using: configuration
+ )
+ }
+ }
+
+ func refreshedPlayableDetails(for item: PlexMediaItem) async throws -> PlexMediaItem {
+ try await connectionStore.perform { configuration in
+ try await client.fetchMediaMetadata(ratingKey: item.ratingKey, using: configuration)
+ }
+ }
+
+ func continuousPlayQueue(for item: PlexMediaItem) async throws -> PlexPlaybackQueue {
+ try await connectionStore.perform { configuration in
+ let endpoints = try await self.libraryProviderEndpoints(using: configuration)
+ guard let playQueuePath = endpoints.playQueuePath else {
+ throw PlexAPIError.missingLibraryPlayQueueFeature
+ }
+ return try await client.createContinuousPlayQueue(
+ for: item,
+ endpointPath: playQueuePath,
+ using: configuration
+ )
+ }
+ }
+
+ func cinemaPlayQueue(
+ for item: PlexMediaItem,
+ extrasPrefixCount: Int
+ ) async throws -> PlexPlaybackQueue {
+ try await connectionStore.perform { configuration in
+ let endpoints = try await self.libraryProviderEndpoints(using: configuration)
+ guard let playQueuePath = endpoints.playQueuePath else {
+ throw PlexAPIError.missingLibraryPlayQueueFeature
+ }
+ return try await client.createCinemaPlayQueue(
+ for: item,
+ extrasPrefixCount: extrasPrefixCount,
+ endpointPath: playQueuePath,
+ using: configuration
+ )
+ }
+ }
+
+ func refreshPlayQueueWindow(
+ queueID: Int,
+ centeredOn playQueueItemID: String
+ ) async throws -> PlexPlayQueuePage {
+ try await connectionStore.perform { configuration in
+ let endpoints = try await self.libraryProviderEndpoints(using: configuration)
+ guard let playQueuePath = endpoints.playQueuePath else {
+ throw PlexAPIError.missingLibraryPlayQueueFeature
+ }
+ return try await client.fetchPlayQueuePage(
+ queueID: queueID,
+ endpointPath: playQueuePath,
+ centeredOn: playQueueItemID,
+ using: configuration
+ )
+ }
+ }
+
+ func addToPlayQueue(
+ _ item: PlexMediaItem,
+ queueID: Int,
+ insertion: PlexPlayQueueInsertion
+ ) async throws -> PlexPlayQueuePage {
+ try await connectionStore.perform { configuration in
+ let endpoints = try await self.libraryProviderEndpoints(using: configuration)
+ guard let playQueuePath = endpoints.playQueuePath else {
+ throw PlexAPIError.missingLibraryPlayQueueFeature
+ }
+ return try await client.addToPlayQueue(
+ item,
+ queueID: queueID,
+ insertion: insertion,
+ endpointPath: playQueuePath,
+ using: configuration
+ )
+ }
+ }
+
+ func setPlayQueueShuffled(
+ _ shuffled: Bool,
+ queueID: Int
+ ) async throws -> PlexPlayQueuePage {
+ try await connectionStore.perform { configuration in
+ let endpoints = try await self.libraryProviderEndpoints(using: configuration)
+ guard let playQueuePath = endpoints.playQueuePath else {
+ throw PlexAPIError.missingLibraryPlayQueueFeature
+ }
+ return try await client.setPlayQueueShuffled(
+ shuffled,
+ queueID: queueID,
+ endpointPath: playQueuePath,
+ using: configuration
+ )
+ }
+ }
+
+ func removePlayQueueItem(
+ queueID: Int,
+ playQueueItemID: String
+ ) async throws -> PlexPlayQueuePage {
+ try await connectionStore.perform { configuration in
+ let endpoints = try await self.libraryProviderEndpoints(using: configuration)
+ guard let playQueuePath = endpoints.playQueuePath else {
+ throw PlexAPIError.missingLibraryPlayQueueFeature
+ }
+ return try await client.removePlayQueueItem(
+ queueID: queueID,
+ playQueueItemID: playQueueItemID,
+ endpointPath: playQueuePath,
+ using: configuration
+ )
+ }
+ }
+
+ func movePlayQueueItem(
+ queueID: Int,
+ move: PlexPlayQueueItemMove
+ ) async throws -> PlexPlayQueuePage {
+ try await connectionStore.perform { configuration in
+ let endpoints = try await self.libraryProviderEndpoints(using: configuration)
+ guard let playQueuePath = endpoints.playQueuePath else {
+ throw PlexAPIError.missingLibraryPlayQueueFeature
+ }
+ return try await client.movePlayQueueItem(
+ queueID: queueID,
+ move: move,
+ endpointPath: playQueuePath,
+ using: configuration
+ )
+ }
+ }
+
+ func resetPlayQueue(queueID: Int) async throws -> PlexPlayQueuePage {
+ try await connectionStore.perform { configuration in
+ let endpoints = try await self.libraryProviderEndpoints(using: configuration)
+ guard let playQueuePath = endpoints.playQueuePath else {
+ throw PlexAPIError.missingLibraryPlayQueueFeature
+ }
+ return try await client.resetPlayQueue(
+ queueID: queueID,
+ endpointPath: playQueuePath,
+ using: configuration
+ )
+ }
+ }
+
+ func playableDetails(for item: PlexMediaItem) async throws -> PlexMediaItem {
+ if item.isPlayable {
+ return item
+ }
+ return try await connectionStore.perform { configuration in
+ try await client.fetchMediaMetadata(ratingKey: item.ratingKey, using: configuration)
+ }
+ }
+
+ func isUpdatingWatchedState(for item: PlexMediaItem) -> Bool {
+ watchedStateMutationRatingKeys.contains(item.ratingKey)
+ }
+
+ func supportsWatchedStateMutation(for item: PlexMediaItem) -> Bool {
+ item.supportsWatchedStateMutation
+ && presentedProviderConnectionKey == activeProviderConnectionKey
+ && presentedProviderEndpoints?.supportsWatchedStateMutation == true
+ }
+
+ var supportsPersonalRatings: Bool {
+ presentedProviderConnectionKey == activeProviderConnectionKey
+ && presentedProviderEndpoints?.supportsRating == true
+ }
+
+ func supportsMetadataRefresh(for item: PlexMediaItem) -> Bool {
+ item.supportsMetadataRefresh
+ && presentedProviderConnectionKey == activeProviderConnectionKey
+ && presentedProviderEndpoints?.supportsMetadataRefresh == true
+ }
+
+ func isUpdatingPersonalRating(for item: PlexMediaItem) -> Bool {
+ personalRatingMutationRatingKeys.contains(item.ratingKey)
+ }
+
+ func isRefreshingMetadata(for item: PlexMediaItem) -> Bool {
+ metadataRefreshRatingKeys.contains(item.ratingKey)
+ }
+
+ var supportsRemoveFromContinueWatching: Bool {
+ presentedProviderConnectionKey == activeProviderConnectionKey
+ && presentedProviderEndpoints?.supportsRemoveFromContinueWatching == true
+ }
+
+ func downloadAuthorization(
+ for user: PlexAuthenticatedUser,
+ library: PlexLibrary
+ ) -> PlexDownloadAuthorization? {
+ guard presentedProviderConnectionKey == activeProviderConnectionKey,
+ let presentedProviderEndpoints else {
+ return nil
+ }
+
+ return PlexDownloadAuthorization(
+ user: user,
+ library: library,
+ providerEndpoints: presentedProviderEndpoints
+ )
+ }
+
+ func downloadProviderEndpoints(
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> PlexLibraryProviderEndpoints {
+ try await libraryProviderEndpoints(using: configuration)
+ }
+
+ func resolvedLibraryProviderEndpoints(
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> PlexLibraryProviderEndpoints {
+ try await libraryProviderEndpoints(using: configuration)
+ }
+
+ func isRemovingFromContinueWatching(_ item: PlexMediaItem) -> Bool {
+ continueWatchingRemovalRatingKeys.contains(item.ratingKey)
+ }
+
+ func loadLibraryProviderCapabilities() async {
+ do {
+ _ = try await connectionStore.perform { configuration in
+ try await self.libraryProviderEndpoints(using: configuration)
+ }
+ } catch {
+ presentedProviderEndpoints = nil
+ presentedProviderConnectionKey = nil
+ }
+ }
+
+ @discardableResult
+ func setWatched(_ watched: Bool, for item: PlexMediaItem) async throws -> PlexMediaItem {
+ guard item.supportsWatchedStateMutation else {
+ throw PlexBrowserMutationError.unsupportedWatchedState
+ }
+ guard watchedStateMutationRatingKeys.insert(item.ratingKey).inserted else {
+ throw PlexBrowserMutationError.watchedStateUpdateInProgress
+ }
+ defer { watchedStateMutationRatingKeys.remove(item.ratingKey) }
+
+ let refreshedItem = try await connectionStore.perform { configuration in
+ let endpoints = try await self.libraryProviderEndpoints(using: configuration)
+ try await self.client.setWatched(
+ watched,
+ ratingKey: item.ratingKey,
+ endpoints: endpoints,
+ using: configuration
+ )
+ return try await self.client.fetchMediaMetadata(
+ ratingKey: item.ratingKey,
+ using: configuration
+ )
+ }
+ replaceCachedWatchedState(with: refreshedItem)
+ return refreshedItem
+ }
+
+ @discardableResult
+ func setPersonalRating(_ rating: Double?, for item: PlexMediaItem) async throws -> PlexMediaItem {
+ guard personalRatingMutationRatingKeys.insert(item.ratingKey).inserted else {
+ throw PlexBrowserMutationError.personalRatingUpdateInProgress
+ }
+ defer { personalRatingMutationRatingKeys.remove(item.ratingKey) }
+
+ let refreshedItem = try await connectionStore.perform { configuration in
+ let endpoints = try await self.libraryProviderEndpoints(using: configuration)
+ try await self.client.setPersonalRating(
+ rating,
+ ratingKey: item.ratingKey,
+ endpoints: endpoints,
+ using: configuration
+ )
+ return try await self.client.fetchMediaMetadata(
+ ratingKey: item.ratingKey,
+ using: configuration
+ )
+ }
+ replaceCachedUserRating(with: refreshedItem)
+ return refreshedItem
+ }
+
+ func refreshMetadata(for item: PlexMediaItem) async throws {
+ guard item.supportsMetadataRefresh else {
+ throw PlexBrowserMutationError.unsupportedMetadataRefresh
+ }
+ guard metadataRefreshRatingKeys.insert(item.ratingKey).inserted else {
+ throw PlexBrowserMutationError.metadataRefreshInProgress
+ }
+ defer { metadataRefreshRatingKeys.remove(item.ratingKey) }
+
+ try await connectionStore.perform { configuration in
+ let endpoints = try await self.libraryProviderEndpoints(using: configuration)
+ try await self.client.refreshMediaMetadata(
+ ratingKey: item.ratingKey,
+ endpoints: endpoints,
+ using: configuration
+ )
+ }
+ }
+
+ func removeFromContinueWatching(_ item: PlexMediaItem) async throws {
+ guard continueWatchingRemovalRatingKeys.insert(item.ratingKey).inserted else {
+ throw PlexBrowserMutationError.continueWatchingRemovalInProgress
+ }
+ defer { continueWatchingRemovalRatingKeys.remove(item.ratingKey) }
+
+ try await connectionStore.perform { configuration in
+ let endpoints = try await self.libraryProviderEndpoints(using: configuration)
+ try await self.client.removeFromContinueWatching(
+ ratingKey: item.ratingKey,
+ endpoints: endpoints,
+ using: configuration
+ )
+ }
+ removeCachedContinueWatchingItem(ratingKey: item.ratingKey)
+ }
+
+ func markPlayedIfSupported(ratingKey: String) async throws {
+ try await connectionStore.perform { configuration in
+ let endpoints = try await self.libraryProviderEndpoints(using: configuration)
+ guard endpoints.supportsWatchedStateMutation else {
+ return
+ }
+ try await self.client.setWatched(
+ true,
+ ratingKey: ratingKey,
+ endpoints: endpoints,
+ using: configuration
+ )
+ }
+ }
+
+ func reportTimeline(_ update: PlexTimelineUpdate) async -> PlexTimelineResponse? {
+ do {
+ return try await connectionStore.perform { configuration in
+ let endpoints = try await self.libraryProviderEndpoints(using: configuration)
+ guard let timelinePath = endpoints.timelinePath else {
+ throw PlexAPIError.missingLibraryTimelineFeature
+ }
+ return try await self.client.reportTimeline(
+ update,
+ endpointPath: timelinePath,
+ using: configuration
+ )
+ }
+ } catch {
+ // Playback must continue when timeline reporting is temporarily unavailable.
+ return nil
+ }
+ }
+}
+
+extension PlexBrowserStore {
+ var supportsCollectionManagement: Bool {
+ presentedProviderConnectionKey == activeProviderConnectionKey
+ && presentedProviderEndpoints?.supportsCollectionManagement == true
+ }
+
+ var supportsPlaylistCreation: Bool {
+ presentedProviderConnectionKey == activeProviderConnectionKey
+ && presentedProviderEndpoints?.supportsPlaylistManagement == true
+ }
+
+ func supportsPlaylistManagement(for playlist: PlexMediaItem) -> Bool {
+ supportsPlaylistCreation
+ && playlist.type?.lowercased() == "playlist"
+ && playlist.readOnly != true
+ }
+
+ func supportsChildManagement(of parent: PlexMediaItem) -> Bool {
+ switch parent.type?.lowercased() {
+ case "collection":
+ return supportsCollectionManagement && parent.smart != true
+ case "playlist":
+ return supportsPlaylistManagement(for: parent) && parent.smart != true
+ default:
+ return false
+ }
+ }
+
+ func isManagingCollectionOrPlaylist(_ item: PlexMediaItem) -> Bool {
+ collectionPlaylistMutationKeys.contains(mutationKey(for: item))
+ }
+
+ func isCreatingCollection(in library: PlexLibrary) -> Bool {
+ collectionPlaylistMutationKeys.contains("collection:create:\(library.id)")
+ }
+
+ func createCollection(named title: String, in library: PlexLibrary) async throws -> PlexMediaItem {
+ guard let metadataTypeID = library.type.metadataTypeID else {
+ throw PlexAPIError.invalidResponse
+ }
+ let key = "collection:create:\(library.id)"
+ return try await withCollectionPlaylistMutation(key: key) {
+ let createdCollection = try await connectionStore.perform { configuration in
+ let endpoints = try await self.libraryProviderEndpoints(using: configuration)
+ guard endpoints.supportsCollectionManagement,
+ let collectionPath = endpoints.collectionPath else {
+ throw PlexAPIError.libraryManagementUnavailable
+ }
+ return try await self.client.createCollection(
+ title: title,
+ libraryID: library.id,
+ metadataTypeID: metadataTypeID,
+ endpointPath: collectionPath,
+ using: configuration
+ )
+ }
+ if collectionItemsByLibraryID[library.id]?.contains(where: {
+ $0.ratingKey == createdCollection.ratingKey
+ }) != true {
+ collectionItemsByLibraryID[library.id, default: []].append(createdCollection)
+ collectionTotalSizesByLibraryID[library.id] =
+ (collectionTotalSizesByLibraryID[library.id] ?? 0) + 1
+ }
+ await refreshCollectionsAfterConfirmedMutation(in: library)
+ return collections(in: library).first {
+ $0.ratingKey == createdCollection.ratingKey
+ } ?? createdCollection
+ }
+ }
+
+ func createCollection(
+ named title: String,
+ containing item: PlexMediaItem,
+ in library: PlexLibrary
+ ) async throws {
+ let collection = try await createCollection(named: title, in: library)
+ do {
+ try await add(item, to: collection, in: library)
+ } catch is CancellationError {
+ throw CancellationError()
+ } catch {
+ throw PlexBrowserMutationError.collectionCreatedWithoutItem(title: collection.title)
+ }
+ }
+
+ func editableCollections(in library: PlexLibrary) -> [PlexMediaItem] {
+ guard supportsCollectionManagement else {
+ return []
+ }
+ return collections(in: library).filter {
+ $0.type?.lowercased() == "collection" && $0.smart != true
+ }
+ }
+
+ func editablePlaylists(for item: PlexMediaItem) -> [PlexMediaItem] {
+ guard let playlistMediaType = item.playlistMediaType else {
+ return []
+ }
+ return playlists.filter {
+ supportsPlaylistManagement(for: $0)
+ && $0.smart != true
+ && $0.playlistType?.lowercased() == playlistMediaType
+ }
+ }
+
+ func add(
+ _ item: PlexMediaItem,
+ to collection: PlexMediaItem,
+ in library: PlexLibrary
+ ) async throws {
+ guard editableCollections(in: library).contains(where: {
+ $0.ratingKey == collection.ratingKey
+ }) else {
+ throw PlexBrowserMutationError.itemManagementUnavailable
+ }
+ try await withCollectionPlaylistMutation(key: mutationKey(for: collection)) {
+ try await connectionStore.perform { configuration in
+ let endpoints = try await self.libraryProviderEndpoints(using: configuration)
+ guard endpoints.supportsCollectionManagement,
+ let collectionPath = endpoints.collectionPath,
+ let serverIdentifier = configuration.serverIdentifier else {
+ throw PlexAPIError.libraryManagementUnavailable
+ }
+ let uri = try PlexMediaSourceURI.item(
+ item,
+ serverIdentifier: serverIdentifier,
+ providerIdentifier: endpoints.providerIdentifier
+ )
+ try await self.client.addItem(
+ uri: uri,
+ toCollectionID: collection.ratingKey,
+ endpointPath: collectionPath,
+ using: configuration
+ )
+ }
+ await refreshCollectionsAfterConfirmedMutation(in: library)
+ await refreshChildrenAfterConfirmedMutation(of: collection)
+ }
+ }
+
+ func add(_ item: PlexMediaItem, to playlist: PlexMediaItem) async throws {
+ guard editablePlaylists(for: item).contains(where: {
+ $0.ratingKey == playlist.ratingKey
+ }) else {
+ throw PlexBrowserMutationError.itemManagementUnavailable
+ }
+ try await withCollectionPlaylistMutation(key: mutationKey(for: playlist)) {
+ try await connectionStore.perform { configuration in
+ let endpoints = try await self.libraryProviderEndpoints(using: configuration)
+ guard endpoints.supportsPlaylistManagement,
+ let playlistPath = endpoints.playlistPath else {
+ throw PlexAPIError.libraryManagementUnavailable
+ }
+ guard let serverIdentifier = configuration.serverIdentifier else {
+ throw PlexAPIError.missingServerIdentity
+ }
+ let uri = try PlexMediaSourceURI.item(item, serverIdentifier: serverIdentifier)
+ try await self.client.addItem(
+ uri: uri,
+ toPlaylistID: playlist.ratingKey,
+ endpointPath: playlistPath,
+ using: configuration
+ )
+ }
+ await refreshPlaylistsAfterConfirmedMutation()
+ await refreshChildrenAfterConfirmedMutation(of: playlist)
+ }
+ }
+
+ func createPlaylist(named title: String, containing item: PlexMediaItem) async throws {
+ guard title.nilIfBlank != nil, item.playlistMediaType != nil else {
+ throw PlexAPIError.invalidMediaTitle
+ }
+ let key = "playlist:create:\(item.ratingKey)"
+ try await withCollectionPlaylistMutation(key: key) {
+ let createdPlaylist = try await connectionStore.perform { configuration in
+ let endpoints = try await self.libraryProviderEndpoints(using: configuration)
+ guard endpoints.supportsPlaylistManagement,
+ let playlistPath = endpoints.playlistPath else {
+ throw PlexAPIError.libraryManagementUnavailable
+ }
+ guard let serverIdentifier = configuration.serverIdentifier else {
+ throw PlexAPIError.missingServerIdentity
+ }
+ let uri = try PlexMediaSourceURI.item(item, serverIdentifier: serverIdentifier)
+ return try await self.client.createPlaylist(
+ containingItemURI: uri,
+ endpointPath: playlistPath,
+ using: configuration
+ )
+ }
+
+ do {
+ try await connectionStore.perform { configuration in
+ let endpoints = try await self.libraryProviderEndpoints(using: configuration)
+ guard endpoints.supportsPlaylistManagement,
+ let playlistPath = endpoints.playlistPath else {
+ throw PlexAPIError.libraryManagementUnavailable
+ }
+ try await self.client.renamePlaylist(
+ id: createdPlaylist.ratingKey,
+ title: title,
+ endpointPath: playlistPath,
+ using: configuration
+ )
+ }
+ } catch {
+ try? await reloadPlaylists()
+ throw PlexBrowserMutationError.playlistCreatedWithoutRequestedName
+ }
+ await refreshPlaylistsAfterConfirmedMutation()
+ }
+ }
+
+ func renameCollection(
+ _ collection: PlexMediaItem,
+ to title: String,
+ in library: PlexLibrary
+ ) async throws {
+ try await withCollectionPlaylistMutation(key: mutationKey(for: collection)) {
+ try await connectionStore.perform { configuration in
+ let endpoints = try await self.libraryProviderEndpoints(using: configuration)
+ guard endpoints.supportsCollectionManagement,
+ let metadataPath = endpoints.metadataPath else {
+ throw PlexAPIError.libraryManagementUnavailable
+ }
+ try await self.client.renameCollection(
+ id: collection.ratingKey,
+ title: title,
+ metadataEndpointPath: metadataPath,
+ using: configuration
+ )
+ }
+ await refreshCollectionsAfterConfirmedMutation(in: library)
+ }
+ }
+
+ func deleteCollection(_ collection: PlexMediaItem, in library: PlexLibrary) async throws {
+ try await withCollectionPlaylistMutation(key: mutationKey(for: collection)) {
+ try await connectionStore.perform { configuration in
+ let endpoints = try await self.libraryProviderEndpoints(using: configuration)
+ guard endpoints.supportsCollectionManagement else {
+ throw PlexAPIError.libraryManagementUnavailable
+ }
+ try await self.client.deleteCollection(
+ id: collection.ratingKey,
+ libraryID: library.id,
+ using: configuration
+ )
+ }
+ collectionItemsByLibraryID[library.id]?.removeAll {
+ $0.ratingKey == collection.ratingKey
+ }
+ if let totalSize = collectionTotalSizesByLibraryID[library.id] {
+ collectionTotalSizesByLibraryID[library.id] = max(totalSize - 1, 0)
+ }
+ if let childrenPath = collection.childrenPath {
+ childItems[childrenPath] = nil
+ childTotalSizes[childrenPath] = nil
+ childErrorMessages[childrenPath] = nil
+ }
+ }
+ }
+
+ func renamePlaylist(_ playlist: PlexMediaItem, to title: String) async throws {
+ guard supportsPlaylistManagement(for: playlist) else {
+ throw PlexBrowserMutationError.itemManagementUnavailable
+ }
+ try await withCollectionPlaylistMutation(key: mutationKey(for: playlist)) {
+ try await connectionStore.perform { configuration in
+ let endpoints = try await self.libraryProviderEndpoints(using: configuration)
+ guard endpoints.supportsPlaylistManagement,
+ let playlistPath = endpoints.playlistPath else {
+ throw PlexAPIError.libraryManagementUnavailable
+ }
+ try await self.client.renamePlaylist(
+ id: playlist.ratingKey,
+ title: title,
+ endpointPath: playlistPath,
+ using: configuration
+ )
+ }
+ await refreshPlaylistsAfterConfirmedMutation()
+ }
+ }
+
+ func deletePlaylist(_ playlist: PlexMediaItem) async throws {
+ guard supportsPlaylistManagement(for: playlist) else {
+ throw PlexBrowserMutationError.itemManagementUnavailable
+ }
+ try await withCollectionPlaylistMutation(key: mutationKey(for: playlist)) {
+ try await connectionStore.perform { configuration in
+ let endpoints = try await self.libraryProviderEndpoints(using: configuration)
+ guard endpoints.supportsPlaylistManagement,
+ let playlistPath = endpoints.playlistPath else {
+ throw PlexAPIError.libraryManagementUnavailable
+ }
+ try await self.client.deletePlaylist(
+ id: playlist.ratingKey,
+ endpointPath: playlistPath,
+ using: configuration
+ )
+ }
+ playlists.removeAll { $0.ratingKey == playlist.ratingKey }
+ playlistsTotalSize = playlistsTotalSize.map { max($0 - 1, 0) }
+ if let childrenPath = playlist.childrenPath {
+ childItems[childrenPath] = nil
+ childTotalSizes[childrenPath] = nil
+ childErrorMessages[childrenPath] = nil
+ }
+ }
+ }
+
+ func canMoveChild(
+ _ child: PlexMediaItem,
+ in parent: PlexMediaItem,
+ direction: PlexListMoveDirection
+ ) -> Bool {
+ guard supportsChildManagement(of: parent),
+ let childrenPath = parent.childrenPath,
+ let index = childItems[childrenPath]?.firstIndex(where: { $0.id == child.id }) else {
+ return false
+ }
+ switch direction {
+ case .up:
+ return index > 0
+ case .down:
+ return index + 1 < (childItems[childrenPath]?.count ?? 0)
+ }
+ }
+
+ func moveChild(
+ _ child: PlexMediaItem,
+ in parent: PlexMediaItem,
+ direction: PlexListMoveDirection
+ ) async throws {
+ guard supportsChildManagement(of: parent),
+ let childrenPath = parent.childrenPath,
+ let siblings = childItems[childrenPath],
+ let sourceIndex = siblings.firstIndex(where: { $0.id == child.id }) else {
+ throw PlexBrowserMutationError.itemManagementUnavailable
+ }
+
+ let afterItem: PlexMediaItem?
+ switch direction {
+ case .up:
+ guard sourceIndex > 0 else {
+ throw PlexBrowserMutationError.invalidMove
+ }
+ afterItem = sourceIndex == 1 ? nil : siblings[sourceIndex - 2]
+ case .down:
+ guard sourceIndex + 1 < siblings.count else {
+ throw PlexBrowserMutationError.invalidMove
+ }
+ afterItem = siblings[sourceIndex + 1]
+ }
+
+ try await withCollectionPlaylistMutation(key: mutationKey(for: parent)) {
+ try await connectionStore.perform { configuration in
+ switch parent.type?.lowercased() {
+ case "collection":
+ let endpoints = try await self.libraryProviderEndpoints(using: configuration)
+ guard endpoints.supportsCollectionManagement,
+ let collectionPath = endpoints.collectionPath else {
+ throw PlexAPIError.libraryManagementUnavailable
+ }
+ try await self.client.moveCollectionItem(
+ id: child.ratingKey,
+ inCollectionID: parent.ratingKey,
+ afterItemID: afterItem?.ratingKey,
+ endpointPath: collectionPath,
+ using: configuration
+ )
+ case "playlist":
+ let endpoints = try await self.libraryProviderEndpoints(using: configuration)
+ guard endpoints.supportsPlaylistManagement,
+ let playlistPath = endpoints.playlistPath else {
+ throw PlexAPIError.libraryManagementUnavailable
+ }
+ guard let playlistItemID = child.playlistItemID,
+ afterItem == nil || afterItem?.playlistItemID != nil else {
+ throw PlexAPIError.invalidResponse
+ }
+ try await self.client.movePlaylistItem(
+ playlistItemID: playlistItemID,
+ inPlaylistID: parent.ratingKey,
+ afterPlaylistItemID: afterItem?.playlistItemID,
+ endpointPath: playlistPath,
+ using: configuration
+ )
+ default:
+ throw PlexBrowserMutationError.itemManagementUnavailable
+ }
+ }
+ await refreshChildrenAfterConfirmedMutation(of: parent)
+ }
+ }
+
+ func removeChild(_ child: PlexMediaItem, from parent: PlexMediaItem) async throws {
+ guard supportsChildManagement(of: parent) else {
+ throw PlexBrowserMutationError.itemManagementUnavailable
+ }
+ try await withCollectionPlaylistMutation(key: mutationKey(for: parent)) {
+ try await connectionStore.perform { configuration in
+ switch parent.type?.lowercased() {
+ case "collection":
+ let endpoints = try await self.libraryProviderEndpoints(using: configuration)
+ guard endpoints.supportsCollectionManagement,
+ let collectionPath = endpoints.collectionPath else {
+ throw PlexAPIError.libraryManagementUnavailable
+ }
+ try await self.client.removeCollectionItem(
+ id: child.ratingKey,
+ fromCollectionID: parent.ratingKey,
+ endpointPath: collectionPath,
+ using: configuration
+ )
+ case "playlist":
+ let endpoints = try await self.libraryProviderEndpoints(using: configuration)
+ guard endpoints.supportsPlaylistManagement,
+ let playlistPath = endpoints.playlistPath else {
+ throw PlexAPIError.libraryManagementUnavailable
+ }
+ guard let playlistItemID = child.playlistItemID else {
+ throw PlexAPIError.invalidResponse
+ }
+ try await self.client.removePlaylistItem(
+ playlistItemID: playlistItemID,
+ fromPlaylistID: parent.ratingKey,
+ endpointPath: playlistPath,
+ using: configuration
+ )
+ default:
+ throw PlexBrowserMutationError.itemManagementUnavailable
+ }
+ }
+ await refreshChildrenAfterConfirmedMutation(of: parent)
+ }
+ }
+
+ func advertisedLibraryProviderEndpoints(
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> PlexLibraryProviderEndpoints {
+ try await libraryProviderEndpoints(using: configuration)
+ }
+}
+
+private extension PlexBrowserStore {
+ func withCollectionPlaylistMutation(
+ key: String,
+ operation: () async throws -> T
+ ) async throws -> T {
+ guard collectionPlaylistMutationKeys.insert(key).inserted else {
+ throw PlexBrowserMutationError.itemManagementInProgress
+ }
+ defer { collectionPlaylistMutationKeys.remove(key) }
+ return try await operation()
+ }
+
+ func mutationKey(for item: PlexMediaItem) -> String {
+ "\(item.type?.lowercased() ?? "item"):\(item.ratingKey)"
+ }
+
+ func reloadCollections(in library: PlexLibrary) async throws {
+ let page = try await connectionStore.perform { configuration in
+ try await self.client.fetchCollectionsPage(
+ libraryID: library.id,
+ using: configuration,
+ start: 0,
+ size: self.pageSize
+ )
+ }
+ collectionItemsByLibraryID[library.id] = page.items
+ collectionTotalSizesByLibraryID[library.id] = page.totalSize ?? page.items.count
+ collectionErrorMessagesByLibraryID[library.id] = nil
+ }
+
+ func reloadPlaylists() async throws {
+ let page = try await connectionStore.perform { configuration in
+ let endpoints = try await self.libraryProviderEndpoints(using: configuration)
+ guard let playlistPath = endpoints.playlistPath else {
+ throw PlexAPIError.missingLibraryPlaylistFeature
+ }
+ return try await self.client.fetchPlaylistsPage(
+ endpointPath: playlistPath,
+ using: configuration,
+ start: 0,
+ size: self.pageSize
+ )
+ }
+ playlists = page.items
+ playlistsTotalSize = page.totalSize ?? page.items.count
+ playlistsErrorMessage = nil
+ }
+
+ func reloadChildren(of parent: PlexMediaItem) async throws {
+ guard let childrenPath = parent.childrenPath else {
+ throw PlexAPIError.invalidResponse
+ }
+ let page = try await fetchChildrenPage(of: parent, start: 0)
+ childItems[childrenPath] = page.items
+ childTotalSizes[childrenPath] = page.totalSize ?? page.items.count
+ childErrorMessages[childrenPath] = nil
+ }
+
+ func refreshCollectionsAfterConfirmedMutation(in library: PlexLibrary) async {
+ do {
+ try await reloadCollections(in: library)
+ } catch {
+ guard !Task.isCancelled else {
+ return
+ }
+ collectionErrorMessagesByLibraryID[library.id] = error.localizedDescription
+ }
+ }
+
+ func refreshPlaylistsAfterConfirmedMutation() async {
+ do {
+ try await reloadPlaylists()
+ } catch {
+ guard !Task.isCancelled else {
+ return
+ }
+ playlistsErrorMessage = error.localizedDescription
+ }
+ }
+
+ func refreshChildrenAfterConfirmedMutation(of parent: PlexMediaItem) async {
+ guard let childrenPath = parent.childrenPath else {
+ return
+ }
+ do {
+ try await reloadChildren(of: parent)
+ } catch {
+ guard !Task.isCancelled else {
+ return
+ }
+ childErrorMessages[childrenPath] = error.localizedDescription
+ }
+ }
+
+ func libraryProviderEndpoints(
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> PlexLibraryProviderEndpoints {
+ let connectionKey = providerConnectionKey(
+ serverIdentifier: configuration.serverIdentifier,
+ serverURL: configuration.serverURL,
+ authenticationCacheScope: configuration.authenticationCacheScope
+ )
+
+ if let endpoints = providerEndpointsByConnectionKey[connectionKey] {
+ presentedProviderEndpoints = endpoints
+ presentedProviderConnectionKey = connectionKey
+ return endpoints
+ }
+ if let task = providerEndpointTasksByConnectionKey[connectionKey] {
+ let endpoints = try await task.value
+ presentedProviderEndpoints = endpoints
+ presentedProviderConnectionKey = connectionKey
+ return endpoints
+ }
+
+ let task = Task { @MainActor in
+ try await client.fetchLibraryProviderEndpoints(using: configuration)
+ }
+ providerEndpointTasksByConnectionKey[connectionKey] = task
+ defer { providerEndpointTasksByConnectionKey[connectionKey] = nil }
+
+ let endpoints = try await task.value
+ providerEndpointsByConnectionKey[connectionKey] = endpoints
+ presentedProviderEndpoints = endpoints
+ presentedProviderConnectionKey = connectionKey
+ return endpoints
+ }
+
+ var activeProviderConnectionKey: String? {
+ guard let serverURL = connectionStore.resolvedServerURL else {
+ return nil
+ }
+ return providerConnectionKey(
+ serverIdentifier: connectionStore.activeConnection?.serverID
+ ?? connectionStore.settings.selectedServerIdentifier,
+ serverURL: serverURL,
+ authenticationCacheScope: PlexConnectionConfiguration.authenticationCacheScope(
+ for: connectionStore.settings.trimmedServerToken
+ )
+ )
+ }
+
+ func providerConnectionKey(
+ serverIdentifier: String?,
+ serverURL: URL,
+ authenticationCacheScope: String
+ ) -> String {
+ [serverIdentifier ?? "", serverURL.absoluteString, authenticationCacheScope]
+ .joined(separator: "|")
+ }
+
+ func replaceCachedWatchedState(with refreshedItem: PlexMediaItem) {
+ libraryItems = libraryItems.mapValues {
+ replacingWatchedState(in: $0, with: refreshedItem)
+ }
+
+ homeState.hubs = homeState.hubs.map { hub in
+ var updatedHub = hub
+ updatedHub.metadata = replacingWatchedState(in: hub.metadata, with: refreshedItem)
+ return updatedHub
+ }
+ homeState.itemsByHubPath = homeState.itemsByHubPath.mapValues {
+ replacingWatchedState(in: $0, with: refreshedItem)
+ }
+ globalSearchStore.replaceCachedWatchedState(with: refreshedItem)
+ replaceCachedRelatedWatchedState(with: refreshedItem)
+ replaceCachedMediaExtrasWatchedState(with: refreshedItem)
+ collectionItemsByLibraryID = collectionItemsByLibraryID.mapValues {
+ replacingWatchedState(in: $0, with: refreshedItem)
+ }
+ playlists = replacingWatchedState(in: playlists, with: refreshedItem)
+ childItems = childItems.mapValues {
+ replacingWatchedState(in: $0, with: refreshedItem)
+ }
+ }
+
+ func replaceCachedUserRating(with refreshedItem: PlexMediaItem) {
+ libraryItems = libraryItems.mapValues {
+ replacingUserRating(in: $0, with: refreshedItem)
+ }
+
+ homeState.hubs = homeState.hubs.map { hub in
+ var updatedHub = hub
+ updatedHub.metadata = replacingUserRating(in: hub.metadata, with: refreshedItem)
+ return updatedHub
+ }
+ homeState.itemsByHubPath = homeState.itemsByHubPath.mapValues {
+ replacingUserRating(in: $0, with: refreshedItem)
+ }
+ globalSearchStore.replaceCachedUserRating(with: refreshedItem)
+ replaceCachedRelatedUserRating(with: refreshedItem)
+ replaceCachedMediaExtrasUserRating(with: refreshedItem)
+ collectionItemsByLibraryID = collectionItemsByLibraryID.mapValues {
+ replacingUserRating(in: $0, with: refreshedItem)
+ }
+ playlists = replacingUserRating(in: playlists, with: refreshedItem)
+ childItems = childItems.mapValues {
+ replacingUserRating(in: $0, with: refreshedItem)
+ }
+ }
+
+ func removeCachedContinueWatchingItem(ratingKey: String) {
+ let continueWatchingPaths = Set(
+ homeState.hubs
+ .filter(\.isContinueWatching)
+ .compactMap(\.key)
+ )
+
+ homeState.hubs = homeState.hubs.map { hub in
+ guard hub.isContinueWatching else {
+ return hub
+ }
+ var updatedHub = hub
+ updatedHub.metadata.removeAll { $0.ratingKey == ratingKey }
+ return updatedHub
+ }
+
+ for path in continueWatchingPaths {
+ let removedCount = homeState.itemsByHubPath[path]?.count(where: {
+ $0.ratingKey == ratingKey
+ }) ?? 0
+ homeState.itemsByHubPath[path]?.removeAll { $0.ratingKey == ratingKey }
+ if let totalSize = homeState.totalSizesByHubPath[path], removedCount > 0 {
+ homeState.totalSizesByHubPath[path] = max(totalSize - removedCount, 0)
+ }
+ }
+ }
+
+ func replacingWatchedState(
+ in items: [PlexMediaItem],
+ with refreshedItem: PlexMediaItem
+ ) -> [PlexMediaItem] {
+ items.map { item in
+ guard item.ratingKey == refreshedItem.ratingKey else {
+ return item
+ }
+ return item.mergingWatchedState(from: refreshedItem)
+ }
+ }
+
+ func replacingUserRating(
+ in items: [PlexMediaItem],
+ with refreshedItem: PlexMediaItem
+ ) -> [PlexMediaItem] {
+ items.map { item in
+ guard item.ratingKey == refreshedItem.ratingKey else {
+ return item
+ }
+ return item.mergingUserRating(from: refreshedItem)
+ }
+ }
+
+ private func libraryBrowseDefinition(
+ for library: PlexLibrary
+ ) async throws -> PlexLibraryBrowseDefinition {
+ if let definition = libraryBrowseDefinitions[library.id] {
+ return definition
+ }
+ if let task = libraryBrowseDefinitionTasks[library.id] {
+ return try await task.value
+ }
+ let task = Task { @MainActor in
+ try await connectionStore.perform { configuration in
+ let endpoints = try await self.libraryProviderEndpoints(using: configuration)
+ guard let route = endpoints.browseRoute(for: library.id) else {
+ throw PlexAPIError.missingLibraryBrowseRoute
+ }
+ return try await client.fetchLibraryBrowseDefinition(
+ sectionPath: route.sectionPath,
+ contentPath: route.contentPath,
+ using: configuration
+ )
+ }
+ }
+ libraryBrowseDefinitionTasks[library.id] = task
+ defer { libraryBrowseDefinitionTasks[library.id] = nil }
+
+ let definition = try await task.value
+ libraryBrowseDefinitions[library.id] = definition
+ return definition
+ }
+
+ private func fetchPage(
+ request: PlexLibraryRequest,
+ definition: PlexLibraryBrowseDefinition,
+ start: Int
+ ) async throws -> PlexMediaPage {
+ let selectedDefinition = try definition.selecting(request.browseOptions.contentTypePath)
+ return try await connectionStore.perform { configuration in
+ try await client.fetchMediaPage(
+ contentPath: selectedDefinition.contentPath,
+ using: configuration,
+ start: start,
+ size: pageSize,
+ searchQuery: request.searchQuery,
+ browseOptions: request.browseOptions
+ )
+ }
+ }
+
+ private func fetchChildrenPage(of item: PlexMediaItem, start: Int) async throws -> PlexMediaPage {
+ try await connectionStore.perform { configuration in
+ try await client.fetchMediaChildren(
+ of: item,
+ using: configuration,
+ start: start,
+ size: pageSize
+ )
+ }
+ }
+
+}
+
+enum PlexBrowserMutationError: LocalizedError {
+ case unsupportedWatchedState
+ case watchedStateUpdateInProgress
+ case personalRatingUpdateInProgress
+ case unsupportedMetadataRefresh
+ case metadataRefreshInProgress
+ case continueWatchingRemovalInProgress
+ case itemManagementUnavailable
+ case itemManagementInProgress
+ case invalidMove
+ case playlistCreatedWithoutRequestedName
+ case collectionCreatedWithoutItem(title: String)
+
+ var errorDescription: String? {
+ switch self {
+ case .unsupportedWatchedState:
+ "Plex does not expose watched status for this item type."
+ case .watchedStateUpdateInProgress:
+ "This item's watched status is already being updated."
+ case .personalRatingUpdateInProgress:
+ "This item's personal rating is already being updated."
+ case .unsupportedMetadataRefresh:
+ "Plex does not expose metadata refresh for this item type."
+ case .metadataRefreshInProgress:
+ "Plex is already starting a metadata refresh for this item."
+ case .continueWatchingRemovalInProgress:
+ "This item is already being removed from Continue Watching."
+ case .itemManagementUnavailable:
+ "Plex does not allow this collection or playlist to be changed."
+ case .itemManagementInProgress:
+ "This collection or playlist is already being changed."
+ case .invalidMove:
+ "That item cannot move farther in this direction."
+ case .playlistCreatedWithoutRequestedName:
+ "Plex created the playlist but did not apply its requested name. Rename it from Playlists."
+ case let .collectionCreatedWithoutItem(title):
+ "Plex created \(title), but the item was not added. You can add it to that collection after retrying the connection."
+ }
+ }
+}
diff --git a/Sources/PlexBar/Stores/PlexConnectionStore.swift b/PlexBar/Stores/PlexConnectionStore.swift
similarity index 77%
rename from Sources/PlexBar/Stores/PlexConnectionStore.swift
rename to PlexBar/Stores/PlexConnectionStore.swift
index 9c8d2b8..5082867 100644
--- a/Sources/PlexBar/Stores/PlexConnectionStore.swift
+++ b/PlexBar/Stores/PlexConnectionStore.swift
@@ -1,3 +1,4 @@
+import PlexModels
import Foundation
import Observation
@@ -32,6 +33,13 @@ final class PlexConnectionStore {
selectedServer != nil
}
+ var accountCacheScope: String {
+ PlexConnectionConfiguration.accountCacheScope(
+ serverIdentifier: settings.selectedServerIdentifier,
+ token: settings.trimmedServerToken
+ )
+ }
+
func updateAvailableServers(_ servers: [PlexServerResource]) {
serversByID = Dictionary(uniqueKeysWithValues: servers.map { ($0.id, $0) })
@@ -74,7 +82,8 @@ final class PlexConnectionStore {
return PlexConnectionConfiguration(
serverURL: activeConnection.url,
token: settings.trimmedServerToken,
- clientContext: PlexClientContext(clientIdentifier: settings.clientIdentifier)
+ clientContext: PlexClientContext(clientIdentifier: settings.clientIdentifier),
+ serverIdentifier: activeConnection.serverID
)
}
@@ -82,17 +91,37 @@ final class PlexConnectionStore {
}
func perform(_ operation: (PlexConnectionConfiguration) async throws -> T) async throws -> T {
+ let requestedAccountScope = accountCacheScope
do {
let configuration = try await currentConfiguration()
- return try await operation(configuration)
+ try validateAccountScope(configuration, expected: requestedAccountScope)
+ let result = try await operation(configuration)
+ try validateAccountScope(configuration, expected: requestedAccountScope)
+ return result
} catch {
guard error.isPlexConnectivityFailure else {
throw error
}
}
+ guard accountCacheScope == requestedAccountScope else {
+ throw CancellationError()
+ }
let configuration = try await currentConfiguration(forceRefresh: true)
- return try await operation(configuration)
+ try validateAccountScope(configuration, expected: requestedAccountScope)
+ let result = try await operation(configuration)
+ try validateAccountScope(configuration, expected: requestedAccountScope)
+ return result
+ }
+
+ private func validateAccountScope(
+ _ configuration: PlexConnectionConfiguration,
+ expected requestedAccountScope: String
+ ) throws {
+ guard configuration.accountCacheScope == requestedAccountScope,
+ accountCacheScope == requestedAccountScope else {
+ throw CancellationError()
+ }
}
private func resolveConfiguration(forceRefresh: Bool) async throws -> PlexConnectionConfiguration {
@@ -129,7 +158,8 @@ final class PlexConnectionStore {
return PlexConnectionConfiguration(
serverURL: resolvedConnection.url,
token: settings.trimmedServerToken,
- clientContext: clientContext
+ clientContext: clientContext,
+ serverIdentifier: resolvedConnection.serverID
)
} catch {
activeConnection = nil
diff --git a/PlexBar/Stores/PlexDownloadCreationStore.swift b/PlexBar/Stores/PlexDownloadCreationStore.swift
new file mode 100644
index 0000000..4b42fb5
--- /dev/null
+++ b/PlexBar/Stores/PlexDownloadCreationStore.swift
@@ -0,0 +1,258 @@
+import PlexModels
+import Foundation
+
+@MainActor
+final class PlexDownloadCreationStore {
+ private let authStore: PlexAuthStore
+ private let connectionStore: PlexConnectionStore
+ private let libraryStore: PlexLibraryStore
+ private let browserStore: PlexBrowserStore
+ private let transferCoordinator: PlexDownloadTransferCoordinator
+ private let playbackCapabilities: PlexPlaybackCapabilities
+
+ init(
+ authStore: PlexAuthStore,
+ connectionStore: PlexConnectionStore,
+ libraryStore: PlexLibraryStore,
+ browserStore: PlexBrowserStore,
+ transferCoordinator: PlexDownloadTransferCoordinator,
+ playbackCapabilities: PlexPlaybackCapabilities = NativePlaybackCapabilityProbe.current()
+ ) {
+ self.authStore = authStore
+ self.connectionStore = connectionStore
+ self.libraryStore = libraryStore
+ self.browserStore = browserStore
+ self.transferCoordinator = transferCoordinator
+ self.playbackCapabilities = playbackCapabilities
+ }
+
+ func start() async throws {
+ try await transferCoordinator.start()
+ }
+
+ func decisionParameters(
+ for item: PlexMediaItem,
+ source: PlexPlaybackSource,
+ sessionIdentifier: String,
+ clientProfileName: String? = "generic",
+ clientProfileExtra: String? = nil
+ ) throws -> PlexDownloadDecisionParameters {
+ let mediaKind = item.media.indices.contains(source.mediaIndex)
+ ? PlexPlaybackMediaKind(media: item.media[source.mediaIndex])
+ : nil
+ let nativeDirectPlaySupported = playbackCapabilities.directPlayPath(
+ for: item,
+ source: source
+ ) != nil
+ return try connectionStore.settings.downloadPreferences.decisionParameters(
+ for: item,
+ source: source,
+ sessionIdentifier: sessionIdentifier,
+ nativeDirectPlaySupported: nativeDirectPlaySupported,
+ clientProfileName: clientProfileName,
+ clientProfileExtra: clientProfileExtra
+ ?? mediaKind.map(playbackCapabilities.downloadClientProfileExtra(for:))
+ )
+ }
+
+ func authorization(
+ forLibraryID libraryID: String
+ ) async throws -> PlexDownloadCreationAuthorization {
+ guard connectionStore.settings.hasAuthenticatedAccount,
+ let user = authStore.authenticatedUser else {
+ throw PlexDownloadCreationAuthorizationError.signedOut
+ }
+ guard let library = libraryStore.libraries.first(where: { $0.id == libraryID }) else {
+ throw PlexDownloadCreationAuthorizationError.unavailableLibrary
+ }
+
+ let configuration = try await connectionStore.currentConfiguration()
+ guard let serverIdentifier = configuration.serverIdentifier?.nilIfBlank else {
+ throw PlexDownloadCreationAuthorizationError.missingServerIdentity
+ }
+ let endpoints = try await browserStore.downloadProviderEndpoints(using: configuration)
+ let facts = PlexDownloadAuthorization(
+ user: user,
+ library: library,
+ providerEndpoints: endpoints
+ )
+ try Self.requireAuthorization(facts)
+
+ let authorization = PlexDownloadCreationAuthorization(
+ scope: PlexDownloadAuthorizationScope(
+ accountID: user.id,
+ serverIdentifier: serverIdentifier,
+ serverURL: Self.normalizedServerURL(configuration.serverURL),
+ libraryID: library.id,
+ providerIdentifier: endpoints.providerIdentifier
+ ),
+ facts: facts
+ )
+ guard isCurrent(authorization) else {
+ throw PlexDownloadCreationAuthorizationError.authorizationExpired
+ }
+ return authorization
+ }
+
+ func isCurrentlyAuthorized(forLibraryID libraryID: String) -> Bool {
+ guard connectionStore.settings.hasAuthenticatedAccount,
+ let user = authStore.authenticatedUser,
+ let library = libraryStore.libraries.first(where: { $0.id == libraryID }),
+ let facts = browserStore.downloadAuthorization(
+ for: user,
+ library: library
+ ) else {
+ return false
+ }
+ return facts.isAuthorized
+ }
+
+ func schedule(
+ _ transferRequest: PlexDownloadTransferRequest,
+ forLibraryID libraryID: String,
+ transferID: UUID = UUID(),
+ createdAt: Date = Date()
+ ) async throws -> PlexDownloadTransferRecord {
+ let authorization = try await authorization(forLibraryID: libraryID)
+ return try await schedule(
+ transferRequest,
+ authorization: authorization,
+ transferID: transferID,
+ createdAt: createdAt
+ )
+ }
+
+ func schedule(
+ _ transferRequest: PlexDownloadTransferRequest,
+ authorization: PlexDownloadCreationAuthorization,
+ transferID: UUID = UUID(),
+ createdAt: Date = Date()
+ ) async throws -> PlexDownloadTransferRecord {
+ guard isCurrent(authorization) else {
+ throw PlexDownloadCreationAuthorizationError.authorizationExpired
+ }
+ guard Self.matches(transferRequest, authorization: authorization) else {
+ throw PlexDownloadCreationAuthorizationError.mismatchedTransfer
+ }
+
+ let record: PlexDownloadTransferRecord
+ do {
+ record = try await transferCoordinator.schedule(
+ transferRequest,
+ transferID: transferID,
+ createdAt: createdAt,
+ authorizationCheck: { @MainActor [weak self] in
+ self?.isCurrent(authorization) == true
+ }
+ )
+ } catch PlexDownloadTransferError.authorizationExpired {
+ throw PlexDownloadCreationAuthorizationError.authorizationExpired
+ }
+
+ guard isCurrent(authorization) else {
+ try? await transferCoordinator.cancel(transferID: record.id)
+ throw PlexDownloadCreationAuthorizationError.authorizationExpired
+ }
+ return record
+ }
+
+ func isCurrent(_ authorization: PlexDownloadCreationAuthorization) -> Bool {
+ guard connectionStore.settings.hasAuthenticatedAccount,
+ let user = authStore.authenticatedUser,
+ user.id == authorization.scope.accountID,
+ connectionStore.settings.selectedServerIdentifier?.nilIfBlank
+ == authorization.scope.serverIdentifier,
+ let serverURL = connectionStore.resolvedServerURL,
+ Self.normalizedServerURL(serverURL) == authorization.scope.serverURL,
+ let library = libraryStore.libraries.first(where: {
+ $0.id == authorization.scope.libraryID
+ }),
+ let facts = browserStore.downloadAuthorization(
+ for: user,
+ library: library
+ ),
+ facts == authorization.facts,
+ facts.isAuthorized,
+ browserStore.presentedProviderEndpoints?.providerIdentifier
+ == authorization.scope.providerIdentifier else {
+ return false
+ }
+ return true
+ }
+
+ private static func requireAuthorization(
+ _ authorization: PlexDownloadAuthorization
+ ) throws {
+ guard authorization.accountHasDownloadsEntitlement else {
+ throw PlexDownloadCreationAuthorizationError.accountNotEntitled
+ }
+ guard authorization.serverAllowsSync == true else {
+ throw PlexDownloadCreationAuthorizationError.serverDisallowsDownloads
+ }
+ guard authorization.libraryAllowsSync == true else {
+ throw PlexDownloadCreationAuthorizationError.libraryDisallowsDownloads
+ }
+ guard authorization.libraryProviderSupportsDownloads else {
+ throw PlexDownloadCreationAuthorizationError.providerDisallowsDownloads
+ }
+ }
+
+ private static func matches(
+ _ transferRequest: PlexDownloadTransferRequest,
+ authorization: PlexDownloadCreationAuthorization
+ ) -> Bool {
+ let identity = transferRequest.packageIdentity
+ guard identity.accountID == authorization.scope.accountID,
+ identity.serverIdentifier == authorization.scope.serverIdentifier,
+ transferRequest.request.url.map({
+ sameOrigin($0, authorization.scope.serverURL)
+ }) == true,
+ transferRequest.request.url?.path
+ == "/downloadQueue/\(identity.queueID)/item/\(identity.queueItemID)/media",
+ let decision = try? JSONDecoder()
+ .decode(PlexDownloadQueueDecisionEnvelope.self, from: transferRequest.decisionData)
+ .mediaContainer,
+ decision.allowSync == true,
+ let metadata = decision.metadata.first(where: {
+ $0.ratingKey == identity.ratingKey
+ }),
+ metadata.librarySectionID == authorization.scope.libraryID,
+ metadata.key?.nilIfBlank == identity.metadataKey.nilIfBlank else {
+ return false
+ }
+ return true
+ }
+
+ private static func normalizedServerURL(_ url: URL) -> URL {
+ guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
+ return url
+ }
+ while components.path.count > 1, components.path.hasSuffix("/") {
+ components.path.removeLast()
+ }
+ return components.url ?? url
+ }
+
+ private static func sameOrigin(_ lhs: URL, _ rhs: URL) -> Bool {
+ guard let left = URLComponents(url: lhs, resolvingAgainstBaseURL: false),
+ let right = URLComponents(url: rhs, resolvingAgainstBaseURL: false) else {
+ return false
+ }
+ return left.scheme?.lowercased() == right.scheme?.lowercased()
+ && left.host?.lowercased() == right.host?.lowercased()
+ && effectivePort(left) == effectivePort(right)
+ && left.user == nil
+ && left.password == nil
+ }
+
+ private static func effectivePort(_ components: URLComponents) -> Int? {
+ if let port = components.port {
+ return port
+ }
+ return switch components.scheme?.lowercased() {
+ case "http": 80
+ case "https": 443
+ default: nil
+ }
+ }
+}
diff --git a/PlexBar/Stores/PlexDownloadHandoffStore.swift b/PlexBar/Stores/PlexDownloadHandoffStore.swift
new file mode 100644
index 0000000..0837a9c
--- /dev/null
+++ b/PlexBar/Stores/PlexDownloadHandoffStore.swift
@@ -0,0 +1,247 @@
+import PlexModels
+import Foundation
+
+final class PlexDownloadHandoffStore: @unchecked Sendable {
+ static let handoffDirectoryExtension = "plexhandoff"
+ static let manifestFileName = "handoff.json"
+ static let mediaFileName = "media"
+
+ private static let incomingDirectoryName = "Incoming"
+ private static let stagingPrefix = ".staging-"
+
+ private let rootURL: URL
+ private let lock = NSLock()
+
+ init(rootURL: URL = PlexDownloadPackageStore.defaultRootURL()) {
+ self.rootURL = rootURL.standardizedFileURL.resolvingSymlinksInPath()
+ }
+
+ func accept(
+ temporaryFileURL: URL,
+ transferID: UUID,
+ response: URLResponse?
+ ) -> Result {
+ lock.lock()
+ defer { lock.unlock() }
+
+ do {
+ return .success(try acceptLocked(
+ temporaryFileURL: temporaryFileURL,
+ transferID: transferID,
+ response: response
+ ))
+ } catch let error as PlexDownloadHandoffError {
+ return .failure(error)
+ } catch {
+ return .failure(.publicationFailed)
+ }
+ }
+
+ func handoff(for transferID: UUID) -> Result {
+ lock.lock()
+ defer { lock.unlock() }
+
+ do {
+ let incomingURL = try prepareIncomingDirectory()
+ let url = handoffURL(for: transferID, in: incomingURL)
+ guard FileManager.default.fileExists(atPath: url.path) else {
+ return .success(nil)
+ }
+ return .success(try inspectHandoff(at: url, expectedTransferID: transferID))
+ } catch let error as PlexDownloadHandoffError {
+ return .failure(error)
+ } catch {
+ return .failure(.publicationFailed)
+ }
+ }
+
+ func removeHandoff(for transferID: UUID) throws {
+ lock.lock()
+ defer { lock.unlock() }
+
+ let incomingURL = try prepareIncomingDirectory()
+ let url = handoffURL(for: transferID, in: incomingURL)
+ guard FileManager.default.fileExists(atPath: url.path) else {
+ return
+ }
+ try FileManager.default.removeItem(at: url)
+ }
+
+ func reconcile(validTransferIDs: Set) throws -> Int {
+ lock.lock()
+ defer { lock.unlock() }
+
+ let fileManager = FileManager.default
+ let incomingURL = try prepareIncomingDirectory()
+ let children = try fileManager.contentsOfDirectory(
+ at: incomingURL,
+ includingPropertiesForKeys: [.isDirectoryKey],
+ options: [.skipsSubdirectoryDescendants]
+ )
+ var removedCount = 0
+
+ for child in children {
+ if child.lastPathComponent.hasPrefix(Self.stagingPrefix) {
+ try fileManager.removeItem(at: child)
+ removedCount += 1
+ continue
+ }
+ guard child.pathExtension == Self.handoffDirectoryExtension else {
+ continue
+ }
+ let transferID = UUID(
+ uuidString: child.deletingPathExtension().lastPathComponent
+ )
+ guard transferID.map({ !validTransferIDs.contains($0) }) ?? true else {
+ continue
+ }
+ try fileManager.removeItem(at: child)
+ removedCount += 1
+ }
+ return removedCount
+ }
+
+ private func acceptLocked(
+ temporaryFileURL: URL,
+ transferID: UUID,
+ response: URLResponse?
+ ) throws -> PlexDownloadHandoff {
+ guard let response = response as? HTTPURLResponse else {
+ throw PlexDownloadHandoffError.invalidResponse
+ }
+ guard (200...299).contains(response.statusCode) else {
+ throw PlexDownloadHandoffError.serverStatus(response.statusCode)
+ }
+ guard Self.isRegularNonSymbolicFile(at: temporaryFileURL) else {
+ throw PlexDownloadHandoffError.invalidTemporaryFile
+ }
+
+ let fileManager = FileManager.default
+ let incomingURL = try prepareIncomingDirectory()
+ let finalURL = handoffURL(for: transferID, in: incomingURL)
+ if fileManager.fileExists(atPath: finalURL.path) {
+ do {
+ return try inspectHandoff(
+ at: finalURL,
+ expectedTransferID: transferID
+ )
+ } catch {
+ throw PlexDownloadHandoffError.existingHandoffIsInvalid
+ }
+ }
+
+ let stagingURL = incomingURL.appendingPathComponent(
+ Self.stagingPrefix + UUID().uuidString,
+ isDirectory: true
+ )
+ try fileManager.createDirectory(at: stagingURL, withIntermediateDirectories: false)
+ var shouldRemoveStaging = true
+ defer {
+ if shouldRemoveStaging {
+ try? fileManager.removeItem(at: stagingURL)
+ }
+ }
+
+ let mediaURL = stagingURL.appendingPathComponent(Self.mediaFileName)
+ try fileManager.moveItem(at: temporaryFileURL, to: mediaURL)
+ let suggestedExtension = response.suggestedFilename?
+ .nilIfBlank
+ .map { URL(fileURLWithPath: $0).pathExtension.nilIfBlank }
+ ?? nil
+ let manifest = PlexDownloadHandoffManifest(
+ transferID: transferID,
+ statusCode: response.statusCode,
+ contentType: response.mimeType?.nilIfBlank,
+ suggestedFileExtension: suggestedExtension
+ )
+ let manifestData = try Self.makeEncoder().encode(manifest)
+ try manifestData.write(
+ to: stagingURL.appendingPathComponent(Self.manifestFileName),
+ options: .atomic
+ )
+ _ = try inspectHandoff(at: stagingURL, expectedTransferID: transferID)
+ try fileManager.moveItem(at: stagingURL, to: finalURL)
+ shouldRemoveStaging = false
+ return try inspectHandoff(at: finalURL, expectedTransferID: transferID)
+ }
+
+ private func inspectHandoff(
+ at directoryURL: URL,
+ expectedTransferID: UUID
+ ) throws -> PlexDownloadHandoff {
+ guard Self.isDirectoryNonSymbolic(at: directoryURL) else {
+ throw PlexDownloadHandoffError.existingHandoffIsInvalid
+ }
+ let directoryURL = directoryURL.resolvingSymlinksInPath()
+ let manifestURL = directoryURL.appendingPathComponent(Self.manifestFileName)
+ guard Self.isRegularNonSymbolicFile(at: manifestURL),
+ let data = try? Data(contentsOf: manifestURL),
+ let manifest = try? Self.makeDecoder().decode(
+ PlexDownloadHandoffManifest.self,
+ from: data
+ ),
+ manifest.schemaVersion == PlexDownloadHandoffManifest.currentSchemaVersion,
+ manifest.transferID == expectedTransferID,
+ (200...299).contains(manifest.statusCode) else {
+ throw PlexDownloadHandoffError.existingHandoffIsInvalid
+ }
+ let mediaURL = directoryURL.appendingPathComponent(Self.mediaFileName)
+ guard Self.isRegularNonSymbolicFile(at: mediaURL) else {
+ throw PlexDownloadHandoffError.existingHandoffIsInvalid
+ }
+ return PlexDownloadHandoff(
+ manifest: manifest,
+ directoryURL: directoryURL,
+ mediaURL: mediaURL
+ )
+ }
+
+ private func prepareIncomingDirectory() throws -> URL {
+ let url = rootURL.appendingPathComponent(
+ Self.incomingDirectoryName,
+ isDirectory: true
+ )
+ try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
+ return url
+ }
+
+ private func handoffURL(for transferID: UUID, in incomingURL: URL) -> URL {
+ incomingURL.appendingPathComponent(
+ "\(transferID.uuidString).\(Self.handoffDirectoryExtension)",
+ isDirectory: true
+ )
+ }
+
+ private static func isRegularNonSymbolicFile(at url: URL) -> Bool {
+ guard let values = try? url.resourceValues(forKeys: [
+ .isRegularFileKey,
+ .isSymbolicLinkKey,
+ ]) else {
+ return false
+ }
+ return values.isRegularFile == true && values.isSymbolicLink != true
+ }
+
+ private static func isDirectoryNonSymbolic(at url: URL) -> Bool {
+ guard let values = try? url.resourceValues(forKeys: [
+ .isDirectoryKey,
+ .isSymbolicLinkKey,
+ ]) else {
+ return false
+ }
+ return values.isDirectory == true && values.isSymbolicLink != true
+ }
+
+ private static func makeEncoder() -> JSONEncoder {
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ encoder.outputFormatting = [.sortedKeys]
+ return encoder
+ }
+
+ private static func makeDecoder() -> JSONDecoder {
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return decoder
+ }
+}
diff --git a/PlexBar/Stores/PlexDownloadJobRegistry.swift b/PlexBar/Stores/PlexDownloadJobRegistry.swift
new file mode 100644
index 0000000..7e5d4e8
--- /dev/null
+++ b/PlexBar/Stores/PlexDownloadJobRegistry.swift
@@ -0,0 +1,168 @@
+import Foundation
+
+actor PlexDownloadJobRegistry {
+ private struct Document: Codable {
+ static let currentSchemaVersion = 1
+
+ let schemaVersion: Int
+ let jobs: [PlexDownloadJob]
+ }
+
+ private let rootURL: URL
+ private var cachedJobs: [UUID: PlexDownloadJob]?
+
+ init(rootURL: URL = PlexDownloadPackageStore.defaultRootURL()) {
+ self.rootURL = rootURL.standardizedFileURL.resolvingSymlinksInPath()
+ }
+
+ func jobs() throws -> [PlexDownloadJob] {
+ try loadIfNeeded().values.sorted {
+ if $0.createdAt != $1.createdAt {
+ return $0.createdAt < $1.createdAt
+ }
+ return $0.id.uuidString < $1.id.uuidString
+ }
+ }
+
+ func save(_ job: PlexDownloadJob) throws {
+ var jobs = try loadIfNeeded()
+ jobs[job.id] = job
+ try persist(jobs)
+ cachedJobs = jobs
+ }
+
+ func remove(withID id: UUID) throws {
+ var jobs = try loadIfNeeded()
+ guard jobs.removeValue(forKey: id) != nil else { return }
+ try persist(jobs)
+ cachedJobs = jobs
+ }
+
+ private func loadIfNeeded() throws -> [UUID: PlexDownloadJob] {
+ if let cachedJobs { return cachedJobs }
+ let url = registryURL
+ guard FileManager.default.fileExists(atPath: url.path) else {
+ cachedJobs = [:]
+ return [:]
+ }
+ let data = try Data(contentsOf: url)
+ let document = try Self.decoder.decode(Document.self, from: data)
+ guard document.schemaVersion == Document.currentSchemaVersion,
+ Set(document.jobs.map(\.id)).count == document.jobs.count else {
+ throw PlexDownloadTransferError.registryUnavailable
+ }
+ let jobs = Dictionary(uniqueKeysWithValues: document.jobs.map { ($0.id, $0) })
+ cachedJobs = jobs
+ return jobs
+ }
+
+ private func persist(_ jobs: [UUID: PlexDownloadJob]) throws {
+ try FileManager.default.createDirectory(at: registryURL.deletingLastPathComponent(), withIntermediateDirectories: true)
+ let document = Document(
+ schemaVersion: Document.currentSchemaVersion,
+ jobs: jobs.values.sorted { $0.id.uuidString < $1.id.uuidString }
+ )
+ try Self.encoder.encode(document).write(to: registryURL, options: .atomic)
+ }
+
+ private var registryURL: URL {
+ rootURL
+ .appendingPathComponent("Jobs", isDirectory: true)
+ .appendingPathComponent("registry.json")
+ }
+
+ private static var encoder: JSONEncoder {
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ encoder.outputFormatting = [.sortedKeys]
+ return encoder
+ }
+
+ private static var decoder: JSONDecoder {
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return decoder
+ }
+}
+
+actor PlexOfflinePlaybackRegistry {
+ private struct Document: Codable {
+ static let currentSchemaVersion = 1
+
+ let schemaVersion: Int
+ let records: [PlexOfflinePlaybackRecord]
+ }
+
+ private let rootURL: URL
+ private var cachedRecords: [UUID: PlexOfflinePlaybackRecord]?
+
+ init(rootURL: URL = PlexDownloadPackageStore.defaultRootURL()) {
+ self.rootURL = rootURL.standardizedFileURL.resolvingSymlinksInPath()
+ }
+
+ func records() throws -> [PlexOfflinePlaybackRecord] {
+ Array(try loadIfNeeded().values)
+ }
+
+ func record(for packageID: UUID) throws -> PlexOfflinePlaybackRecord? {
+ try loadIfNeeded()[packageID]
+ }
+
+ func save(_ record: PlexOfflinePlaybackRecord) throws {
+ var records = try loadIfNeeded()
+ records[record.id] = record
+ try persist(records)
+ cachedRecords = records
+ }
+
+ func remove(packageID: UUID) throws {
+ var records = try loadIfNeeded()
+ guard records.removeValue(forKey: packageID) != nil else { return }
+ try persist(records)
+ cachedRecords = records
+ }
+
+ private func loadIfNeeded() throws -> [UUID: PlexOfflinePlaybackRecord] {
+ if let cachedRecords { return cachedRecords }
+ guard FileManager.default.fileExists(atPath: registryURL.path) else {
+ cachedRecords = [:]
+ return [:]
+ }
+ let document = try Self.decoder.decode(Document.self, from: Data(contentsOf: registryURL))
+ guard document.schemaVersion == Document.currentSchemaVersion,
+ Set(document.records.map(\.id)).count == document.records.count else {
+ throw PlexDownloadTransferError.registryUnavailable
+ }
+ let records = Dictionary(uniqueKeysWithValues: document.records.map { ($0.id, $0) })
+ cachedRecords = records
+ return records
+ }
+
+ private func persist(_ records: [UUID: PlexOfflinePlaybackRecord]) throws {
+ try FileManager.default.createDirectory(at: registryURL.deletingLastPathComponent(), withIntermediateDirectories: true)
+ let document = Document(
+ schemaVersion: Document.currentSchemaVersion,
+ records: records.values.sorted { $0.id.uuidString < $1.id.uuidString }
+ )
+ try Self.encoder.encode(document).write(to: registryURL, options: .atomic)
+ }
+
+ private var registryURL: URL {
+ rootURL
+ .appendingPathComponent("Playback", isDirectory: true)
+ .appendingPathComponent("offline-progress.json")
+ }
+
+ private static var encoder: JSONEncoder {
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ encoder.outputFormatting = [.sortedKeys]
+ return encoder
+ }
+
+ private static var decoder: JSONDecoder {
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return decoder
+ }
+}
diff --git a/PlexBar/Stores/PlexDownloadPackageStore.swift b/PlexBar/Stores/PlexDownloadPackageStore.swift
new file mode 100644
index 0000000..58cb9dd
--- /dev/null
+++ b/PlexBar/Stores/PlexDownloadPackageStore.swift
@@ -0,0 +1,557 @@
+import PlexModels
+import AVFoundation
+import Foundation
+
+actor PlexDownloadPackageStore {
+ static let packageDirectoryExtension = "plexdownload"
+ static let manifestFileName = "manifest.json"
+ static let decisionFileName = "decision.json"
+ static let artworkFileName = "artwork.jpg"
+
+ private static let packagesDirectoryName = "Packages"
+ private static let stagingPrefix = ".staging-"
+
+ private let rootURL: URL
+ private let fileManager: FileManager
+
+ init(
+ rootURL: URL = PlexDownloadPackageStore.defaultRootURL(),
+ fileManager: FileManager = .default
+ ) {
+ self.rootURL = rootURL.standardizedFileURL.resolvingSymlinksInPath()
+ self.fileManager = fileManager
+ }
+
+ static func defaultRootURL() -> URL {
+ URL.applicationSupportDirectory
+ .appendingPathComponent("PlexBar", isDirectory: true)
+ .appendingPathComponent("Downloads", isDirectory: true)
+ }
+
+ func validateMetadata(
+ identity: PlexDownloadPackageIdentity,
+ title: String,
+ decisionData: Data,
+ mediaFileExtension: String?
+ ) throws {
+ try validate(identity: identity)
+ guard title.nilIfBlank != nil else {
+ throw PlexDownloadPackageStoreError.invalidTitle
+ }
+ guard Self.isValidDecisionDocument(decisionData, identity: identity) else {
+ throw PlexDownloadPackageStoreError.invalidDecision
+ }
+ _ = try normalizedMediaFileExtension(mediaFileExtension)
+ }
+
+ func publish(
+ identity: PlexDownloadPackageIdentity,
+ title: String,
+ mediaType: String?,
+ decisionData: Data,
+ downloadedFileURL: URL,
+ mediaFileExtension: String?,
+ contentType: String?,
+ completedAt: Date = Date()
+ ) async throws -> PlexDownloadPackage {
+ try validateMetadata(
+ identity: identity,
+ title: title,
+ decisionData: decisionData,
+ mediaFileExtension: mediaFileExtension
+ )
+ let title = title.trimmingCharacters(in: .whitespacesAndNewlines)
+ try validateRegularFile(at: downloadedFileURL)
+
+ let normalizedExtension = try normalizedMediaFileExtension(mediaFileExtension)
+ let mediaFileName = normalizedExtension.map { "media.\($0)" } ?? "media"
+ let packagesURL = try preparePackagesDirectory()
+ let stagingURL = packagesURL.appendingPathComponent(
+ Self.stagingPrefix + UUID().uuidString,
+ isDirectory: true
+ )
+ let publishedURL = packageURL(for: identity.packageID, in: packagesURL)
+ var shouldRemoveStaging = true
+
+ try fileManager.createDirectory(
+ at: stagingURL,
+ withIntermediateDirectories: false
+ )
+ defer {
+ if shouldRemoveStaging {
+ try? fileManager.removeItem(at: stagingURL)
+ }
+ }
+
+ let stagedMediaURL = stagingURL.appendingPathComponent(mediaFileName)
+ try fileManager.moveItem(at: downloadedFileURL, to: stagedMediaURL)
+ let mediaByteCount = try regularFileByteCount(at: stagedMediaURL)
+ guard mediaByteCount > 0 else {
+ throw PlexDownloadPackageStoreError.invalidDownloadedFile
+ }
+ try await validatePromisedEmbeddedSubtitle(
+ decisionData: decisionData,
+ mediaURL: stagedMediaURL
+ )
+
+ let decisionURL = stagingURL.appendingPathComponent(Self.decisionFileName)
+ try decisionData.write(to: decisionURL, options: .atomic)
+
+ let manifest = PlexDownloadPackageManifest(
+ identity: identity,
+ title: title,
+ mediaType: mediaType?.nilIfBlank,
+ mediaFileName: mediaFileName,
+ mediaByteCount: mediaByteCount,
+ contentType: contentType?.nilIfBlank,
+ decisionFileName: Self.decisionFileName,
+ completedAt: completedAt
+ )
+ let manifestData = try Self.makeManifestEncoder().encode(manifest)
+ try manifestData.write(
+ to: stagingURL.appendingPathComponent(Self.manifestFileName),
+ options: .atomic
+ )
+
+ guard case .success = inspectPackage(
+ at: stagingURL,
+ expectedPackageID: identity.packageID
+ ) else {
+ throw PlexDownloadPackageStoreError.invalidPackage
+ }
+
+ if fileManager.fileExists(atPath: publishedURL.path) {
+ _ = try fileManager.replaceItemAt(
+ publishedURL,
+ withItemAt: stagingURL,
+ backupItemName: nil,
+ options: .usingNewMetadataOnly
+ )
+ } else {
+ try fileManager.moveItem(at: stagingURL, to: publishedURL)
+ }
+ shouldRemoveStaging = false
+
+ guard case .success(let package) = inspectPackage(at: publishedURL) else {
+ throw PlexDownloadPackageStoreError.invalidPackage
+ }
+ return package
+ }
+
+ func reconcile() throws -> PlexDownloadPackageReconciliation {
+ let packagesURL = try preparePackagesDirectory()
+ let childURLs = try fileManager.contentsOfDirectory(
+ at: packagesURL,
+ includingPropertiesForKeys: [.isDirectoryKey],
+ options: [.skipsSubdirectoryDescendants]
+ )
+ var removedStagingPackageCount = 0
+ var packages: [PlexDownloadPackage] = []
+ var integrityIssues: [PlexDownloadPackageIntegrityIssue] = []
+
+ for childURL in childURLs {
+ if childURL.lastPathComponent.hasPrefix(Self.stagingPrefix) {
+ try fileManager.removeItem(at: childURL)
+ removedStagingPackageCount += 1
+ continue
+ }
+ guard childURL.pathExtension == Self.packageDirectoryExtension else {
+ continue
+ }
+
+ switch inspectPackage(at: childURL) {
+ case .success(let package):
+ packages.append(package)
+ case .failure(let issue):
+ integrityIssues.append(issue)
+ }
+ }
+
+ packages.sort {
+ if $0.manifest.completedAt != $1.manifest.completedAt {
+ return $0.manifest.completedAt > $1.manifest.completedAt
+ }
+ return $0.id.uuidString < $1.id.uuidString
+ }
+ integrityIssues.sort {
+ $0.packageURL.lastPathComponent < $1.packageURL.lastPathComponent
+ }
+
+ return PlexDownloadPackageReconciliation(
+ packages: packages,
+ integrityIssues: integrityIssues,
+ removedStagingPackageCount: removedStagingPackageCount
+ )
+ }
+
+ func package(withID packageID: UUID) throws -> PlexDownloadPackage? {
+ let packagesURL = try preparePackagesDirectory()
+ let url = packageURL(for: packageID, in: packagesURL)
+ guard fileManager.fileExists(atPath: url.path) else {
+ return nil
+ }
+ guard case .success(let package) = inspectPackage(at: url) else {
+ throw PlexDownloadPackageStoreError.invalidPackage
+ }
+ return package
+ }
+
+ func offlineMedia() throws -> [PlexOfflineMedia] {
+ try reconcile().packages.map(offlineMedia(from:))
+ }
+
+ func offlineMedia(
+ accountID: Int,
+ serverIdentifier: String
+ ) throws -> [PlexOfflineMedia] {
+ guard accountID > 0,
+ let serverIdentifier = serverIdentifier.nilIfBlank else {
+ return []
+ }
+ return try offlineMedia().filter {
+ $0.package.manifest.identity.accountID == accountID
+ && $0.package.manifest.identity.serverIdentifier == serverIdentifier
+ }
+ }
+
+ func offlineMedia(withID packageID: UUID) throws -> PlexOfflineMedia? {
+ guard let package = try package(withID: packageID) else {
+ return nil
+ }
+ return try offlineMedia(from: package)
+ }
+
+ func offlineMedia(
+ withID packageID: UUID,
+ accountID: Int,
+ serverIdentifier: String
+ ) throws -> PlexOfflineMedia? {
+ guard accountID > 0,
+ let serverIdentifier = serverIdentifier.nilIfBlank,
+ let media = try offlineMedia(withID: packageID),
+ media.package.manifest.identity.accountID == accountID,
+ media.package.manifest.identity.serverIdentifier == serverIdentifier else {
+ return nil
+ }
+ return media
+ }
+
+ func installArtwork(
+ _ data: Data,
+ for packageID: UUID
+ ) throws -> PlexDownloadPackage {
+ guard !data.isEmpty,
+ let package = try package(withID: packageID) else {
+ throw PlexDownloadPackageStoreError.invalidPackage
+ }
+ let artworkURL = package.packageURL.appendingPathComponent(Self.artworkFileName)
+ try data.write(to: artworkURL, options: .atomic)
+
+ let old = package.manifest
+ let manifest = PlexDownloadPackageManifest(
+ schemaVersion: old.schemaVersion,
+ identity: old.identity,
+ title: old.title,
+ mediaType: old.mediaType,
+ mediaFileName: old.mediaFileName,
+ mediaByteCount: old.mediaByteCount,
+ contentType: old.contentType,
+ decisionFileName: old.decisionFileName,
+ artworkFileName: Self.artworkFileName,
+ completedAt: old.completedAt
+ )
+ try Self.makeManifestEncoder().encode(manifest).write(
+ to: package.packageURL.appendingPathComponent(Self.manifestFileName),
+ options: .atomic
+ )
+ guard case .success(let updated) = inspectPackage(at: package.packageURL) else {
+ throw PlexDownloadPackageStoreError.invalidPackage
+ }
+ return updated
+ }
+
+ func removePackage(withID packageID: UUID) throws {
+ let packagesURL = try preparePackagesDirectory()
+ let url = packageURL(for: packageID, in: packagesURL)
+ guard fileManager.fileExists(atPath: url.path) else {
+ return
+ }
+ try fileManager.removeItem(at: url)
+ }
+
+ private func inspectPackage(
+ at packageURL: URL,
+ expectedPackageID: UUID? = nil
+ ) -> Result {
+ let packageURL = packageURL.resolvingSymlinksInPath()
+ let manifestURL = packageURL.appendingPathComponent(Self.manifestFileName)
+ guard let manifestData = try? Data(contentsOf: manifestURL),
+ let manifest = try? Self.makeManifestDecoder().decode(
+ PlexDownloadPackageManifest.self,
+ from: manifestData
+ ) else {
+ return .failure(PlexDownloadPackageIntegrityIssue(
+ packageURL: packageURL,
+ reason: .unreadableManifest
+ ))
+ }
+ guard manifest.schemaVersion == PlexDownloadPackageManifest.currentSchemaVersion else {
+ return .failure(PlexDownloadPackageIntegrityIssue(
+ packageURL: packageURL,
+ reason: .unsupportedSchemaVersion(manifest.schemaVersion)
+ ))
+ }
+ let packageID = expectedPackageID?.uuidString
+ ?? packageURL.deletingPathExtension().lastPathComponent
+ guard packageID == manifest.identity.packageID.uuidString else {
+ return .failure(PlexDownloadPackageIntegrityIssue(
+ packageURL: packageURL,
+ reason: .packageIdentityMismatch
+ ))
+ }
+ guard isValid(manifest: manifest) else {
+ return .failure(PlexDownloadPackageIntegrityIssue(
+ packageURL: packageURL,
+ reason: .invalidManifest
+ ))
+ }
+
+ let decisionURL = packageURL.appendingPathComponent(manifest.decisionFileName)
+ guard isRegularFile(at: decisionURL),
+ let decisionData = try? Data(contentsOf: decisionURL),
+ Self.isValidDecisionDocument(
+ decisionData,
+ identity: manifest.identity
+ ) else {
+ return .failure(PlexDownloadPackageIntegrityIssue(
+ packageURL: packageURL,
+ reason: .missingDecision
+ ))
+ }
+
+ let mediaURL = packageURL.appendingPathComponent(manifest.mediaFileName)
+ guard let actualMediaByteCount = try? regularFileByteCount(at: mediaURL) else {
+ return .failure(PlexDownloadPackageIntegrityIssue(
+ packageURL: packageURL,
+ reason: .missingMedia
+ ))
+ }
+ guard actualMediaByteCount == manifest.mediaByteCount else {
+ return .failure(PlexDownloadPackageIntegrityIssue(
+ packageURL: packageURL,
+ reason: .mediaSizeMismatch(
+ expected: manifest.mediaByteCount,
+ actual: actualMediaByteCount
+ )
+ ))
+ }
+
+ let artworkURL: URL?
+ if let artworkFileName = manifest.artworkFileName {
+ let candidate = packageURL.appendingPathComponent(artworkFileName)
+ guard isRegularFile(at: candidate) else {
+ return .failure(PlexDownloadPackageIntegrityIssue(
+ packageURL: packageURL,
+ reason: .invalidManifest
+ ))
+ }
+ artworkURL = candidate
+ } else {
+ artworkURL = nil
+ }
+
+ return .success(PlexDownloadPackage(
+ manifest: manifest,
+ packageURL: packageURL,
+ mediaURL: mediaURL,
+ decisionURL: decisionURL,
+ artworkURL: artworkURL
+ ))
+ }
+
+ private func preparePackagesDirectory() throws -> URL {
+ let packagesURL = rootURL.appendingPathComponent(
+ Self.packagesDirectoryName,
+ isDirectory: true
+ )
+ try fileManager.createDirectory(
+ at: packagesURL,
+ withIntermediateDirectories: true
+ )
+ return packagesURL
+ }
+
+ private func validatePromisedEmbeddedSubtitle(
+ decisionData: Data,
+ mediaURL: URL
+ ) async throws {
+ guard Self.decisionPromisesEmbeddedSubtitle(decisionData) else { return }
+ let asset = AVURLAsset(url: mediaURL)
+ do {
+ let group = try await asset.loadMediaSelectionGroup(for: .legible)
+ guard group?.options.isEmpty == false else {
+ throw PlexDownloadPackageStoreError.missingEmbeddedSubtitle
+ }
+ } catch let error as PlexDownloadPackageStoreError {
+ throw error
+ } catch {
+ throw PlexDownloadPackageStoreError.missingEmbeddedSubtitle
+ }
+ }
+
+ private func packageURL(for packageID: UUID, in packagesURL: URL) -> URL {
+ packagesURL.appendingPathComponent(
+ "\(packageID.uuidString).\(Self.packageDirectoryExtension)",
+ isDirectory: true
+ )
+ }
+
+ private func validate(identity: PlexDownloadPackageIdentity) throws {
+ guard isValid(identity: identity) else {
+ throw PlexDownloadPackageStoreError.invalidIdentity
+ }
+ }
+
+ private func isValid(identity: PlexDownloadPackageIdentity) -> Bool {
+ identity.accountID.map { $0 > 0 } == true
+ && identity.serverIdentifier.nilIfBlank != nil
+ && identity.queueID > 0
+ && identity.queueItemID > 0
+ && identity.ratingKey.nilIfBlank != nil
+ && Self.isValidMetadataKey(identity.metadataKey)
+ }
+
+ private func normalizedMediaFileExtension(_ value: String?) throws -> String? {
+ guard let value = value?.nilIfBlank else {
+ return nil
+ }
+ let normalized = value.hasPrefix(".") ? String(value.dropFirst()) : value
+ guard !normalized.isEmpty,
+ normalized.count <= 12,
+ normalized.unicodeScalars.allSatisfy({
+ CharacterSet.alphanumerics.contains($0)
+ }) else {
+ throw PlexDownloadPackageStoreError.invalidMediaFileExtension
+ }
+ return normalized.lowercased()
+ }
+
+ private func validateRegularFile(at url: URL) throws {
+ guard isRegularFile(at: url) else {
+ throw PlexDownloadPackageStoreError.invalidDownloadedFile
+ }
+ }
+
+ private func isRegularFile(at url: URL) -> Bool {
+ guard let values = try? url.resourceValues(forKeys: [
+ .isRegularFileKey,
+ .isSymbolicLinkKey,
+ ]) else {
+ return false
+ }
+ return values.isRegularFile == true && values.isSymbolicLink != true
+ }
+
+ private func regularFileByteCount(at url: URL) throws -> Int64 {
+ try validateRegularFile(at: url)
+ let values = try url.resourceValues(forKeys: [.fileSizeKey])
+ guard let fileSize = values.fileSize, fileSize >= 0 else {
+ throw PlexDownloadPackageStoreError.invalidDownloadedFile
+ }
+ return Int64(fileSize)
+ }
+
+ private func isValid(manifest: PlexDownloadPackageManifest) -> Bool {
+ manifest.title.nilIfBlank != nil
+ && manifest.mediaByteCount > 0
+ && Self.isSafeFileName(manifest.mediaFileName)
+ && manifest.artworkFileName.map(Self.isSafeFileName) != false
+ && manifest.decisionFileName == Self.decisionFileName
+ && isValid(identity: manifest.identity)
+ }
+
+ private func offlineMedia(from package: PlexDownloadPackage) throws -> PlexOfflineMedia {
+ let data = try Data(contentsOf: package.decisionURL)
+ let decision = try JSONDecoder()
+ .decode(PlexDownloadQueueDecisionEnvelope.self, from: data)
+ .mediaContainer
+ guard let item = decision.metadata.first(where: {
+ $0.ratingKey == package.manifest.identity.ratingKey
+ && $0.key == package.manifest.identity.metadataKey
+ }) else {
+ throw PlexDownloadPackageStoreError.invalidDecision
+ }
+ return PlexOfflineMedia(package: package, item: item)
+ }
+
+ private static func isSafeFileName(_ value: String) -> Bool {
+ value.nilIfBlank != nil
+ && value != "."
+ && value != ".."
+ && !value.contains("/")
+ && !value.contains(":")
+ }
+
+ private static func isValidMetadataKey(_ key: String) -> Bool {
+ guard let key = key.nilIfBlank,
+ key.hasPrefix("/library/metadata/"),
+ let components = URLComponents(string: key),
+ components.scheme == nil,
+ components.host == nil,
+ components.query == nil,
+ components.fragment == nil else {
+ return false
+ }
+ let pathComponents = components.path.split(separator: "/", omittingEmptySubsequences: true)
+ return pathComponents.count >= 3
+ && pathComponents[0] == "library"
+ && pathComponents[1] == "metadata"
+ && pathComponents.dropFirst(2).allSatisfy { $0 != "." && $0 != ".." }
+ }
+
+ private static func isValidDecisionDocument(
+ _ data: Data,
+ identity: PlexDownloadPackageIdentity
+ ) -> Bool {
+ guard !data.isEmpty,
+ let envelope = try? JSONDecoder().decode(
+ PlexDownloadQueueDecisionEnvelope.self,
+ from: data
+ ) else {
+ return false
+ }
+ return envelope.mediaContainer.metadata.contains {
+ $0.key == identity.metadataKey && $0.ratingKey == identity.ratingKey
+ }
+ }
+
+ private static func decisionPromisesEmbeddedSubtitle(_ data: Data) -> Bool {
+ guard let decision = try? JSONDecoder()
+ .decode(PlexDownloadQueueDecisionEnvelope.self, from: data)
+ .mediaContainer else {
+ return false
+ }
+ return decision.metadata
+ .flatMap(\.media)
+ .flatMap(\.parts)
+ .flatMap(\.streams)
+ .contains { stream in
+ stream.streamType == 3
+ && stream.selected == true
+ && stream.decision?.lowercased() == "transcode"
+ }
+ }
+
+ private static func makeManifestEncoder() -> JSONEncoder {
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ encoder.outputFormatting = [.sortedKeys]
+ return encoder
+ }
+
+ private static func makeManifestDecoder() -> JSONDecoder {
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return decoder
+ }
+}
diff --git a/PlexBar/Stores/PlexDownloadPreparedAssetStore.swift b/PlexBar/Stores/PlexDownloadPreparedAssetStore.swift
new file mode 100644
index 0000000..d69b1df
--- /dev/null
+++ b/PlexBar/Stores/PlexDownloadPreparedAssetStore.swift
@@ -0,0 +1,45 @@
+import Foundation
+import ImageIO
+
+actor PlexDownloadPreparedAssetStore {
+ private let rootURL: URL
+
+ init(rootURL: URL = PlexDownloadPackageStore.defaultRootURL()) {
+ self.rootURL = rootURL.standardizedFileURL.resolvingSymlinksInPath()
+ }
+
+ func saveArtwork(_ data: Data, packageID: UUID) throws {
+ guard !data.isEmpty,
+ CGImageSourceCreateWithData(data as CFData, nil) != nil else {
+ return
+ }
+ let url = artworkURL(packageID: packageID)
+ try FileManager.default.createDirectory(
+ at: url.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ try data.write(to: url, options: .atomic)
+ }
+
+ func artwork(packageID: UUID) throws -> Data? {
+ let url = artworkURL(packageID: packageID)
+ guard FileManager.default.fileExists(atPath: url.path) else { return nil }
+ return try Data(contentsOf: url)
+ }
+
+ func remove(packageID: UUID) throws {
+ let url = packageDirectoryURL(packageID: packageID)
+ guard FileManager.default.fileExists(atPath: url.path) else { return }
+ try FileManager.default.removeItem(at: url)
+ }
+
+ private func packageDirectoryURL(packageID: UUID) -> URL {
+ rootURL
+ .appendingPathComponent("Prepared", isDirectory: true)
+ .appendingPathComponent(packageID.uuidString, isDirectory: true)
+ }
+
+ private func artworkURL(packageID: UUID) -> URL {
+ packageDirectoryURL(packageID: packageID).appendingPathComponent("artwork.jpg")
+ }
+}
diff --git a/PlexBar/Stores/PlexDownloadTransferCoordinator.swift b/PlexBar/Stores/PlexDownloadTransferCoordinator.swift
new file mode 100644
index 0000000..cdd450e
--- /dev/null
+++ b/PlexBar/Stores/PlexDownloadTransferCoordinator.swift
@@ -0,0 +1,486 @@
+import PlexModels
+import Foundation
+import os
+
+actor PlexDownloadTransferCoordinator {
+ static let backgroundSessionIdentifier = "\(AppConstants.bundleIdentifier).downloads"
+ private static let logger = Logger(
+ subsystem: AppConstants.bundleIdentifier,
+ category: "Downloads"
+ )
+
+ private let registry: PlexDownloadTransferRegistry
+ private let packageStore: PlexDownloadPackageStore
+ private let handoffStore: PlexDownloadHandoffStore
+ private let session: PlexDownloadTransferSession
+
+ private var eventTask: Task?
+ private var progressByTransferID: [UUID: PlexDownloadTransferProgress] = [:]
+ private var started = false
+
+ init(
+ registry: PlexDownloadTransferRegistry,
+ packageStore: PlexDownloadPackageStore,
+ handoffStore: PlexDownloadHandoffStore,
+ session: PlexDownloadTransferSession
+ ) {
+ self.registry = registry
+ self.packageStore = packageStore
+ self.handoffStore = handoffStore
+ self.session = session
+ }
+
+ static func live(
+ rootURL: URL = PlexDownloadPackageStore.defaultRootURL(),
+ packageStore: PlexDownloadPackageStore? = nil
+ ) -> PlexDownloadTransferCoordinator {
+ let packageStore = packageStore ?? PlexDownloadPackageStore(rootURL: rootURL)
+ let handoffStore = PlexDownloadHandoffStore(rootURL: rootURL)
+ return PlexDownloadTransferCoordinator(
+ registry: PlexDownloadTransferRegistry(rootURL: rootURL),
+ packageStore: packageStore,
+ handoffStore: handoffStore,
+ session: .background(
+ identifier: backgroundSessionIdentifier,
+ handoffStore: handoffStore
+ )
+ )
+ }
+
+ static func inert(
+ rootURL: URL,
+ packageStore: PlexDownloadPackageStore? = nil
+ ) -> PlexDownloadTransferCoordinator {
+ let packageStore = packageStore ?? PlexDownloadPackageStore(rootURL: rootURL)
+ let handoffStore = PlexDownloadHandoffStore(rootURL: rootURL)
+ return PlexDownloadTransferCoordinator(
+ registry: PlexDownloadTransferRegistry(rootURL: rootURL),
+ packageStore: packageStore,
+ handoffStore: handoffStore,
+ session: .inert()
+ )
+ }
+
+ func start() async throws {
+ guard !started else {
+ return
+ }
+ started = true
+ let events = session.events
+ eventTask = Task { [weak self] in
+ for await event in events {
+ guard !Task.isCancelled else {
+ return
+ }
+ await self?.handle(event)
+ }
+ }
+ do {
+ try await recover()
+ } catch {
+ Self.logger.error(
+ "Download transfer recovery failed: \(error.localizedDescription, privacy: .public)"
+ )
+ eventTask?.cancel()
+ eventTask = nil
+ started = false
+ throw error
+ }
+ }
+
+ func schedule(
+ _ transferRequest: PlexDownloadTransferRequest,
+ transferID: UUID = UUID(),
+ createdAt: Date = Date(),
+ authorizationCheck: @escaping @Sendable () async -> Bool = { true }
+ ) async throws -> PlexDownloadTransferRecord {
+ try await start()
+ guard await authorizationCheck() else {
+ throw PlexDownloadTransferError.authorizationExpired
+ }
+ guard Self.isValid(request: transferRequest.request) else {
+ throw PlexDownloadTransferError.invalidRequest
+ }
+ try await packageStore.validateMetadata(
+ identity: transferRequest.packageIdentity,
+ title: transferRequest.title,
+ decisionData: transferRequest.decisionData,
+ mediaFileExtension: transferRequest.mediaFileExtension
+ )
+ let existingRecords = try await registry.records()
+ guard !existingRecords.contains(where: {
+ $0.id == transferID
+ || $0.packageIdentity.packageID
+ == transferRequest.packageIdentity.packageID
+ }) else {
+ throw PlexDownloadTransferError.duplicateTransfer
+ }
+ guard let taskIdentifier = session.createTask(
+ with: transferRequest.request,
+ transferID: transferID
+ ) else {
+ throw PlexDownloadTransferError.taskCreationFailed
+ }
+
+ var record = PlexDownloadTransferRecord(
+ id: transferID,
+ packageIdentity: transferRequest.packageIdentity,
+ title: transferRequest.title,
+ mediaType: transferRequest.mediaType,
+ decisionData: transferRequest.decisionData,
+ mediaFileExtension: transferRequest.mediaFileExtension,
+ contentType: transferRequest.contentType,
+ taskIdentifier: taskIdentifier,
+ createdAt: createdAt
+ )
+ do {
+ try await registry.save(record)
+ } catch {
+ await session.cancelTask(withIdentifier: taskIdentifier)
+ throw PlexDownloadTransferError.registryUnavailable
+ }
+
+ guard await authorizationCheck() else {
+ await session.cancelTask(withIdentifier: taskIdentifier)
+ do {
+ try await registry.remove(withID: transferID)
+ } catch {
+ throw PlexDownloadTransferError.registryUnavailable
+ }
+ throw PlexDownloadTransferError.authorizationExpired
+ }
+
+ await session.resumeTask(withIdentifier: taskIdentifier)
+ record.state = .transferring
+ try? await registry.save(record)
+ return record
+ }
+
+ func cancel(transferID: UUID) async throws {
+ guard let record = try await registry.record(withID: transferID) else {
+ try? handoffStore.removeHandoff(for: transferID)
+ progressByTransferID.removeValue(forKey: transferID)
+ return
+ }
+ await session.cancelTask(withIdentifier: record.taskIdentifier)
+ try? handoffStore.removeHandoff(for: transferID)
+ try await registry.remove(withID: transferID)
+ progressByTransferID.removeValue(forKey: transferID)
+ }
+
+ func pause(transferID: UUID) async throws {
+ guard var record = try await registry.record(withID: transferID),
+ record.state == .transferring else {
+ return
+ }
+ await session.suspendTask(withIdentifier: record.taskIdentifier)
+ record.state = .paused
+ try await registry.save(record)
+ }
+
+ func resume(transferID: UUID) async throws {
+ guard var record = try await registry.record(withID: transferID),
+ record.state == .paused else {
+ return
+ }
+ await session.resumeTask(withIdentifier: record.taskIdentifier)
+ record.state = .transferring
+ record.failure = nil
+ try await registry.save(record)
+ }
+
+ func records() async throws -> [PlexDownloadTransferRecord] {
+ try await registry.records()
+ }
+
+ func progress(for transferID: UUID) -> PlexDownloadTransferProgress? {
+ progressByTransferID[transferID]
+ }
+
+ func progressSnapshot() -> [UUID: PlexDownloadTransferProgress] {
+ progressByTransferID
+ }
+
+ private func recover() async throws {
+ _ = try await packageStore.reconcile()
+ var records = try await registry.records()
+ let recordIDs = Set(records.map(\.id))
+ _ = try handoffStore.reconcile(validTransferIDs: recordIDs)
+ let tasks = await session.tasks()
+ let taskByIdentifier = Dictionary(uniqueKeysWithValues: tasks.map {
+ ($0.taskIdentifier, $0)
+ })
+
+ for task in tasks where !Self.task(task, belongsTo: records) {
+ await session.cancelTask(withIdentifier: task.taskIdentifier)
+ }
+
+ for record in records {
+ do {
+ if try await packageStore.package(
+ withID: record.packageIdentity.packageID
+ ) != nil {
+ await session.cancelTask(withIdentifier: record.taskIdentifier)
+ try? handoffStore.removeHandoff(for: record.id)
+ try await registry.remove(withID: record.id)
+ progressByTransferID.removeValue(forKey: record.id)
+ continue
+ }
+ } catch {
+ await markFailed(record, failure: .publication)
+ continue
+ }
+
+ switch handoffStore.handoff(for: record.id) {
+ case .success(.some(let handoff)):
+ await publish(handoff: handoff, for: record)
+ continue
+ case .failure:
+ await markFailed(record, failure: .handoff)
+ continue
+ case .success(.none):
+ break
+ }
+
+ guard let task = taskByIdentifier[record.taskIdentifier],
+ task.taskDescription == record.id.uuidString else {
+ await markFailed(record, failure: .missingTask)
+ continue
+ }
+ if record.state == .failed {
+ await session.cancelTask(withIdentifier: task.taskIdentifier)
+ continue
+ }
+ switch task.state {
+ case .running:
+ await markTransferring(record, task: task)
+ case .suspended:
+ if record.state == .paused {
+ progressByTransferID[record.id] = PlexDownloadTransferProgress(
+ transferID: record.id,
+ bytesReceived: max(task.countOfBytesReceived, 0),
+ bytesExpected: task.countOfBytesExpectedToReceive > 0
+ ? task.countOfBytesExpectedToReceive
+ : nil
+ )
+ } else {
+ await session.resumeTask(withIdentifier: task.taskIdentifier)
+ await markTransferring(record, task: task)
+ }
+ case .canceling, .completed:
+ await markFailed(record, failure: .transfer)
+ }
+ }
+ records = try await registry.records()
+ _ = try handoffStore.reconcile(validTransferIDs: Set(records.map(\.id)))
+ }
+
+ private func handle(_ event: PlexDownloadTransferEvent) async {
+ switch event {
+ case let .progress(taskIdentifier, description, received, expected):
+ guard let record = await matchingRecord(
+ taskIdentifier: taskIdentifier,
+ description: description
+ ) else {
+ return
+ }
+ progressByTransferID[record.id] = PlexDownloadTransferProgress(
+ transferID: record.id,
+ bytesReceived: max(received, 0),
+ bytesExpected: expected > 0 ? expected : nil
+ )
+ guard record.state != .paused else {
+ return
+ }
+ if record.state != .transferring {
+ var updated = record
+ updated.state = .transferring
+ updated.failure = nil
+ try? await registry.save(updated)
+ }
+
+ case let .waitingForConnectivity(taskIdentifier, description):
+ guard let record = await matchingRecord(
+ taskIdentifier: taskIdentifier,
+ description: description
+ ) else {
+ return
+ }
+ guard record.state != .paused else {
+ return
+ }
+ if record.state != .transferring {
+ var updated = record
+ updated.state = .transferring
+ updated.failure = nil
+ try? await registry.save(updated)
+ }
+
+ case let .handoffCompleted(taskIdentifier, description, result):
+ guard let record = await matchingRecord(
+ taskIdentifier: taskIdentifier,
+ description: description
+ ) else {
+ if case .success(let handoff) = result {
+ try? handoffStore.removeHandoff(for: handoff.manifest.transferID)
+ }
+ return
+ }
+ switch result {
+ case .success(let handoff):
+ var downloaded = record
+ downloaded.state = .downloaded
+ downloaded.failure = nil
+ try? await registry.save(downloaded)
+ await publish(handoff: handoff, for: downloaded)
+ case .failure(let error):
+ await markFailed(
+ record,
+ failure: Self.failure(for: error)
+ )
+ }
+
+ case let .taskCompleted(taskIdentifier, description, errorCode):
+ guard let record = await matchingRecord(
+ taskIdentifier: taskIdentifier,
+ description: description
+ ) else {
+ return
+ }
+ guard record.state != .failed else {
+ return
+ }
+ if errorCode != nil {
+ try? handoffStore.removeHandoff(for: record.id)
+ await markFailed(record, failure: .transfer)
+ } else if case .success(.some(let handoff)) = handoffStore.handoff(
+ for: record.id
+ ) {
+ await publish(handoff: handoff, for: record)
+ } else {
+ await markFailed(record, failure: .handoff)
+ }
+ }
+ }
+
+ private func publish(
+ handoff: PlexDownloadHandoff,
+ for record: PlexDownloadTransferRecord
+ ) async {
+ var publishing = record
+ publishing.state = .publishing
+ publishing.failure = nil
+ try? await registry.save(publishing)
+
+ do {
+ _ = try await packageStore.publish(
+ identity: record.packageIdentity,
+ title: record.title,
+ mediaType: record.mediaType,
+ decisionData: record.decisionData,
+ downloadedFileURL: handoff.mediaURL,
+ mediaFileExtension: handoff.manifest.suggestedFileExtension
+ ?? record.mediaFileExtension,
+ contentType: handoff.manifest.contentType ?? record.contentType,
+ completedAt: handoff.manifest.completedAt
+ )
+ try? handoffStore.removeHandoff(for: record.id)
+ try await registry.remove(withID: record.id)
+ progressByTransferID.removeValue(forKey: record.id)
+ } catch {
+ try? handoffStore.removeHandoff(for: record.id)
+ await markFailed(publishing, failure: .publication)
+ }
+ }
+
+ private func matchingRecord(
+ taskIdentifier: Int,
+ description: String?
+ ) async -> PlexDownloadTransferRecord? {
+ guard let description,
+ let transferID = UUID(uuidString: description),
+ let record = try? await registry.record(withID: transferID),
+ record.taskIdentifier == taskIdentifier else {
+ if let record = try? await registry.records().first(where: {
+ $0.taskIdentifier == taskIdentifier
+ }) {
+ await markFailed(record, failure: .invalidTaskIdentity)
+ }
+ await session.cancelTask(withIdentifier: taskIdentifier)
+ return nil
+ }
+ return record
+ }
+
+ private func markTransferring(
+ _ record: PlexDownloadTransferRecord,
+ task: PlexDownloadTransferTaskSnapshot
+ ) async {
+ var updated = record
+ updated.state = .transferring
+ updated.failure = nil
+ try? await registry.save(updated)
+ progressByTransferID[record.id] = PlexDownloadTransferProgress(
+ transferID: record.id,
+ bytesReceived: max(task.countOfBytesReceived, 0),
+ bytesExpected: task.countOfBytesExpectedToReceive > 0
+ ? task.countOfBytesExpectedToReceive
+ : nil
+ )
+ }
+
+ private func markFailed(
+ _ record: PlexDownloadTransferRecord,
+ failure: PlexDownloadTransferFailure
+ ) async {
+ var updated = record
+ updated.state = .failed
+ updated.failure = failure
+ try? await registry.save(updated)
+ progressByTransferID.removeValue(forKey: record.id)
+ }
+
+ private static func isValid(request: URLRequest) -> Bool {
+ guard request.httpMethod == "GET",
+ request.httpBody == nil,
+ request.httpBodyStream == nil,
+ let url = request.url,
+ let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
+ components.scheme == "http" || components.scheme == "https",
+ components.host?.nilIfBlank != nil,
+ components.user == nil,
+ components.password == nil,
+ request.value(forHTTPHeaderField: "X-Plex-Token")?.nilIfBlank != nil else {
+ return false
+ }
+ return true
+ }
+
+ private static func task(
+ _ task: PlexDownloadTransferTaskSnapshot,
+ belongsTo records: [PlexDownloadTransferRecord]
+ ) -> Bool {
+ guard let description = task.taskDescription,
+ let transferID = UUID(uuidString: description) else {
+ return false
+ }
+ return records.contains {
+ $0.id == transferID && $0.taskIdentifier == task.taskIdentifier
+ }
+ }
+
+ private static func failure(
+ for error: PlexDownloadHandoffError
+ ) -> PlexDownloadTransferFailure {
+ switch error {
+ case .invalidTransferIdentity:
+ .invalidTaskIdentity
+ case .invalidResponse, .serverStatus:
+ .serverResponse
+ case .invalidTemporaryFile,
+ .existingHandoffIsInvalid,
+ .publicationFailed:
+ .handoff
+ }
+ }
+}
diff --git a/PlexBar/Stores/PlexDownloadTransferRegistry.swift b/PlexBar/Stores/PlexDownloadTransferRegistry.swift
new file mode 100644
index 0000000..7317315
--- /dev/null
+++ b/PlexBar/Stores/PlexDownloadTransferRegistry.swift
@@ -0,0 +1,107 @@
+import Foundation
+
+actor PlexDownloadTransferRegistry {
+ private struct Document: Codable {
+ static let currentSchemaVersion = 1
+
+ let schemaVersion: Int
+ let records: [PlexDownloadTransferRecord]
+ }
+
+ private static let transfersDirectoryName = "Transfers"
+ private static let registryFileName = "registry.json"
+
+ private let rootURL: URL
+ private var cachedRecords: [UUID: PlexDownloadTransferRecord]?
+
+ init(rootURL: URL = PlexDownloadPackageStore.defaultRootURL()) {
+ self.rootURL = rootURL.standardizedFileURL.resolvingSymlinksInPath()
+ }
+
+ func records() throws -> [PlexDownloadTransferRecord] {
+ let records = try loadIfNeeded()
+ return records.values.sorted {
+ if $0.createdAt != $1.createdAt {
+ return $0.createdAt < $1.createdAt
+ }
+ return $0.id.uuidString < $1.id.uuidString
+ }
+ }
+
+ func record(withID id: UUID) throws -> PlexDownloadTransferRecord? {
+ try loadIfNeeded()[id]
+ }
+
+ func save(_ record: PlexDownloadTransferRecord) throws {
+ var records = try loadIfNeeded()
+ records[record.id] = record
+ try persist(records)
+ cachedRecords = records
+ }
+
+ func remove(withID id: UUID) throws {
+ var records = try loadIfNeeded()
+ guard records.removeValue(forKey: id) != nil else {
+ return
+ }
+ try persist(records)
+ cachedRecords = records
+ }
+
+ private func loadIfNeeded() throws -> [UUID: PlexDownloadTransferRecord] {
+ if let cachedRecords {
+ return cachedRecords
+ }
+ let url = registryURL()
+ guard FileManager.default.fileExists(atPath: url.path) else {
+ cachedRecords = [:]
+ return [:]
+ }
+ let data = try Data(contentsOf: url)
+ let document = try Self.makeDecoder().decode(Document.self, from: data)
+ guard document.schemaVersion == Document.currentSchemaVersion,
+ Set(document.records.map(\.id)).count == document.records.count else {
+ throw PlexDownloadTransferError.registryUnavailable
+ }
+ let records = Dictionary(uniqueKeysWithValues: document.records.map { ($0.id, $0) })
+ cachedRecords = records
+ return records
+ }
+
+ private func persist(_ records: [UUID: PlexDownloadTransferRecord]) throws {
+ let directoryURL = rootURL.appendingPathComponent(
+ Self.transfersDirectoryName,
+ isDirectory: true
+ )
+ try FileManager.default.createDirectory(
+ at: directoryURL,
+ withIntermediateDirectories: true
+ )
+ let sortedRecords = records.values.sorted { $0.id.uuidString < $1.id.uuidString }
+ let document = Document(
+ schemaVersion: Document.currentSchemaVersion,
+ records: sortedRecords
+ )
+ let data = try Self.makeEncoder().encode(document)
+ try data.write(to: registryURL(), options: .atomic)
+ }
+
+ private func registryURL() -> URL {
+ rootURL
+ .appendingPathComponent(Self.transfersDirectoryName, isDirectory: true)
+ .appendingPathComponent(Self.registryFileName)
+ }
+
+ private static func makeEncoder() -> JSONEncoder {
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ encoder.outputFormatting = [.sortedKeys]
+ return encoder
+ }
+
+ private static func makeDecoder() -> JSONDecoder {
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return decoder
+ }
+}
diff --git a/PlexBar/Stores/PlexDownloadsStore.swift b/PlexBar/Stores/PlexDownloadsStore.swift
new file mode 100644
index 0000000..ecff7f3
--- /dev/null
+++ b/PlexBar/Stores/PlexDownloadsStore.swift
@@ -0,0 +1,1117 @@
+import PlexModels
+import Foundation
+import Observation
+
+@MainActor
+@Observable
+final class PlexDownloadsStore {
+ private(set) var jobs: [PlexDownloadJob] = []
+ private(set) var downloadedMedia: [PlexOfflineMedia] = []
+ private(set) var transferProgress: [UUID: PlexDownloadTransferProgress] = [:]
+ private(set) var automaticDownloadRules: [PlexAutomaticDownloadRule] = []
+ private(set) var refreshingAutomaticRuleIDs: Set = []
+ private(set) var creatingRatingKeys: Set = []
+ private(set) var syncErrorMessages: [UUID: String] = [:]
+ private(set) var startupErrorMessage: String?
+
+ @ObservationIgnored private let authStore: PlexAuthStore
+ @ObservationIgnored private let connectionStore: PlexConnectionStore
+ @ObservationIgnored private let browserStore: PlexBrowserStore
+ @ObservationIgnored private let client: PlexAPIClient
+ @ObservationIgnored private let creationStore: PlexDownloadCreationStore
+ @ObservationIgnored private let transferCoordinator: PlexDownloadTransferCoordinator
+ @ObservationIgnored private let packageStore: PlexDownloadPackageStore
+ @ObservationIgnored private let jobRegistry: PlexDownloadJobRegistry
+ @ObservationIgnored private let playbackRegistry: PlexOfflinePlaybackRegistry
+ @ObservationIgnored private let preparedAssetStore: PlexDownloadPreparedAssetStore
+ @ObservationIgnored private let automaticRuleRegistry: PlexAutomaticDownloadRuleRegistry
+ @ObservationIgnored private let pollingInterval: Duration
+ @ObservationIgnored private let automaticRefreshInterval: Duration
+ @ObservationIgnored private var jobTasks: [UUID: Task] = [:]
+ @ObservationIgnored private var automaticRefreshTask: Task?
+ @ObservationIgnored private var restartPreparationJobIDs: Set = []
+ @ObservationIgnored private var loadedAccountScope: AccountScope?
+ @ObservationIgnored private var hasStarted = false
+ @ObservationIgnored private let maximumConcurrentJobs = 2
+
+ init(
+ authStore: PlexAuthStore,
+ connectionStore: PlexConnectionStore,
+ browserStore: PlexBrowserStore,
+ client: PlexAPIClient,
+ creationStore: PlexDownloadCreationStore,
+ transferCoordinator: PlexDownloadTransferCoordinator,
+ packageStore: PlexDownloadPackageStore,
+ jobRegistry: PlexDownloadJobRegistry = PlexDownloadJobRegistry(),
+ playbackRegistry: PlexOfflinePlaybackRegistry = PlexOfflinePlaybackRegistry(),
+ preparedAssetStore: PlexDownloadPreparedAssetStore = PlexDownloadPreparedAssetStore(),
+ automaticRuleRegistry: PlexAutomaticDownloadRuleRegistry = PlexAutomaticDownloadRuleRegistry(),
+ pollingInterval: Duration = .seconds(2),
+ automaticRefreshInterval: Duration = .seconds(15 * 60)
+ ) {
+ self.authStore = authStore
+ self.connectionStore = connectionStore
+ self.browserStore = browserStore
+ self.client = client
+ self.creationStore = creationStore
+ self.transferCoordinator = transferCoordinator
+ self.packageStore = packageStore
+ self.jobRegistry = jobRegistry
+ self.playbackRegistry = playbackRegistry
+ self.preparedAssetStore = preparedAssetStore
+ self.automaticRuleRegistry = automaticRuleRegistry
+ self.pollingInterval = pollingInterval
+ self.automaticRefreshInterval = automaticRefreshInterval
+ }
+
+ var activeJobs: [PlexDownloadJob] {
+ jobs.filter { $0.state != .failed }
+ }
+
+ var failedJobs: [PlexDownloadJob] {
+ jobs.filter { $0.state == .failed }
+ }
+
+ func start() async {
+ guard !hasStarted else { return }
+ hasStarted = true
+
+ do {
+ try await creationStore.start()
+ try await loadAccountScopedState()
+ try await reconcileWorkflowState()
+ startupErrorMessage = nil
+ if authStore.authenticatedUser != nil {
+ await resumePendingJobs()
+ await synchronizeOfflineProgress()
+ await refreshAutomaticDownloads()
+ }
+ beginAutomaticRefreshLoop()
+ } catch {
+ startupErrorMessage = error.localizedDescription
+ }
+ }
+
+ func reload() async {
+ do {
+ try await loadAccountScopedState()
+ try await reconcileWorkflowState()
+ await refreshAutomaticDownloads()
+ startupErrorMessage = nil
+ } catch {
+ startupErrorMessage = error.localizedDescription
+ }
+ }
+
+ func resumePendingJobs() async {
+ do {
+ try await loadAccountScopedState()
+ try await reconcileWorkflowState()
+ startPendingJobs()
+ startupErrorMessage = nil
+ } catch {
+ startupErrorMessage = error.localizedDescription
+ }
+ }
+
+ func containsDownload(for item: PlexMediaItem) -> Bool {
+ guard let scope = currentAccountScope else { return false }
+ return downloadedMedia.contains {
+ $0.package.manifest.identity.ratingKey == item.ratingKey
+ && $0.package.manifest.identity.accountID == scope.accountID
+ && $0.package.manifest.identity.serverIdentifier == scope.serverIdentifier
+ }
+ }
+
+ func isDownloading(_ item: PlexMediaItem) -> Bool {
+ guard let scope = currentAccountScope else { return false }
+ return creatingRatingKeys.contains(item.ratingKey)
+ || jobs.contains {
+ $0.packageIdentity.ratingKey == item.ratingKey
+ && $0.accountID == scope.accountID
+ && $0.packageIdentity.accountID == scope.accountID
+ && $0.packageIdentity.serverIdentifier == scope.serverIdentifier
+ && $0.state != .failed
+ }
+ }
+
+ func canCreateDownload(
+ for item: PlexMediaItem,
+ libraryID explicitLibraryID: String? = nil
+ ) -> Bool {
+ guard item.supportsOfflineDownload,
+ let libraryID = explicitLibraryID?.nilIfBlank
+ ?? item.librarySectionID?.nilIfBlank else {
+ return false
+ }
+ return creationStore.isCurrentlyAuthorized(forLibraryID: libraryID)
+ }
+
+ func canCreateAutomaticDownloadRule(for item: PlexMediaItem) -> Bool {
+ guard item.supportsAutomaticOfflineDownloads,
+ let libraryID = item.librarySectionID?.nilIfBlank else {
+ return false
+ }
+ return creationStore.isCurrentlyAuthorized(forLibraryID: libraryID)
+ }
+
+ func download(
+ _ candidate: PlexMediaItem,
+ source requestedSource: PlexPlaybackSource? = nil
+ ) async throws {
+ guard candidate.supportsOfflineDownload else {
+ throw PlexDownloadWorkflowError.unsupportedItem
+ }
+ guard !containsDownload(for: candidate) else {
+ throw PlexDownloadWorkflowError.alreadyDownloaded
+ }
+ guard !isDownloading(candidate) else {
+ throw PlexDownloadWorkflowError.alreadyInProgress
+ }
+
+ creatingRatingKeys.insert(candidate.ratingKey)
+ defer { creatingRatingKeys.remove(candidate.ratingKey) }
+
+ let item = try await browserStore.refreshedPlayableDetails(for: candidate)
+ guard let source = requestedSource.flatMap({ requested in
+ item.playbackSource(mediaIndex: requested.mediaIndex)
+ }) ?? item.defaultPlaybackSource else {
+ throw PlexDownloadWorkflowError.unsupportedItem
+ }
+ guard let libraryID = item.librarySectionID?.nilIfBlank
+ ?? candidate.librarySectionID?.nilIfBlank else {
+ throw PlexDownloadWorkflowError.missingLibrary
+ }
+ let authorization = try await creationStore.authorization(forLibraryID: libraryID)
+ let configuration = try await connectionStore.currentConfiguration()
+ guard configuration.serverIdentifier?.nilIfBlank == authorization.scope.serverIdentifier else {
+ throw PlexDownloadCreationAuthorizationError.authorizationExpired
+ }
+
+ let decision = try creationStore.decisionParameters(
+ for: item,
+ source: source,
+ sessionIdentifier: UUID().uuidString
+ )
+ let queue = try await client.fetchOrCreateDownloadQueue(using: configuration)
+ let metadataKey = item.key?.nilIfBlank ?? "/library/metadata/\(item.ratingKey)"
+ let added = try await client.addToDownloadQueue(
+ keys: [metadataKey],
+ queueID: queue.id,
+ decision: decision,
+ using: configuration
+ )
+ guard added.count == 1,
+ let queueItem = added.first,
+ queueItem.key == metadataKey else {
+ throw PlexDownloadWorkflowError.invalidQueueResponse
+ }
+
+ let now = Date()
+ let packageIdentity = PlexDownloadPackageIdentity(
+ accountID: authorization.scope.accountID,
+ serverIdentifier: authorization.scope.serverIdentifier,
+ queueID: queue.id,
+ queueItemID: queueItem.id,
+ metadataKey: metadataKey,
+ ratingKey: item.ratingKey
+ )
+ let job = PlexDownloadJob(
+ id: packageIdentity.packageID,
+ accountID: authorization.scope.accountID,
+ packageIdentity: packageIdentity,
+ libraryID: libraryID,
+ title: item.title,
+ mediaType: item.type,
+ source: source,
+ decisionParameters: decision,
+ createdAt: now,
+ updatedAt: now,
+ state: .waitingForServer,
+ serverPreparationProgress: nil,
+ transferID: nil,
+ errorMessage: nil
+ )
+ try await save(job)
+ if let artworkPath = item.posterArtworkPath,
+ let artworkData = try? await client.fetchDownloadArtwork(
+ path: artworkPath,
+ using: configuration
+ ) {
+ try? await preparedAssetStore.saveArtwork(
+ artworkData,
+ packageID: packageIdentity.packageID
+ )
+ }
+ startPendingJobs()
+ }
+
+ func createAutomaticDownloadRule(
+ for candidate: PlexMediaItem,
+ policy: PlexAutomaticDownloadPolicy,
+ keepsUpToDate: Bool,
+ removesWatchedDownloads: Bool
+ ) async throws {
+ guard candidate.supportsAutomaticOfflineDownloads,
+ let libraryID = candidate.librarySectionID?.nilIfBlank else {
+ throw PlexDownloadWorkflowError.unsupportedAutomaticDownload
+ }
+ let authorization = try await creationStore.authorization(forLibraryID: libraryID)
+ let configuration = try await currentConfiguration(for: authorization)
+ let item = try await client.fetchMediaMetadata(
+ ratingKey: candidate.ratingKey,
+ using: configuration
+ )
+ guard item.supportsAutomaticOfflineDownloads,
+ let childrenPath = item.childrenPath?.nilIfBlank else {
+ throw PlexDownloadWorkflowError.unsupportedAutomaticDownload
+ }
+ guard !automaticDownloadRules.contains(where: {
+ $0.serverIdentifier == authorization.scope.serverIdentifier
+ && $0.sourceRatingKey == item.ratingKey
+ }) else {
+ throw PlexDownloadWorkflowError.automaticDownloadAlreadyExists
+ }
+
+ let rule = PlexAutomaticDownloadRule(
+ id: UUID(),
+ accountID: authorization.scope.accountID,
+ serverIdentifier: authorization.scope.serverIdentifier,
+ libraryID: libraryID,
+ sourceRatingKey: item.ratingKey,
+ sourceChildrenPath: childrenPath,
+ sourceType: item.type?.lowercased() ?? "",
+ title: item.title,
+ posterPath: item.posterArtworkPath,
+ policy: policy,
+ keepsUpToDate: keepsUpToDate,
+ removesWatchedDownloads: removesWatchedDownloads,
+ createdAt: Date(),
+ lastRefreshedAt: nil,
+ lastErrorMessage: nil
+ )
+ try await automaticRuleRegistry.save(rule)
+ automaticDownloadRules.append(rule)
+ sortAutomaticDownloadRules()
+
+ do {
+ try await refreshAutomaticDownload(ruleID: rule.id, allowsNewDownloads: true)
+ } catch {
+ try? await automaticRuleRegistry.remove(withID: rule.id)
+ automaticDownloadRules.removeAll { $0.id == rule.id }
+ throw error
+ }
+ }
+
+ func removeAutomaticDownloadRule(_ rule: PlexAutomaticDownloadRule) async throws {
+ try await automaticRuleRegistry.remove(withID: rule.id)
+ automaticDownloadRules.removeAll { $0.id == rule.id }
+ }
+
+ func refreshAutomaticDownloads() async {
+ guard let accountID = authStore.authenticatedUser?.id,
+ let serverIdentifier = currentServerIdentifier else {
+ return
+ }
+ let matchingRules = automaticDownloadRules.filter {
+ $0.accountID == accountID && $0.serverIdentifier == serverIdentifier
+ }
+ for rule in matchingRules {
+ try? await refreshAutomaticDownload(
+ ruleID: rule.id,
+ allowsNewDownloads: rule.keepsUpToDate
+ )
+ }
+ }
+
+ private func beginAutomaticRefreshLoop() {
+ guard automaticRefreshTask == nil else { return }
+ let interval = automaticRefreshInterval
+ automaticRefreshTask = Task { [weak self] in
+ while !Task.isCancelled {
+ do {
+ try await Task.sleep(for: interval)
+ } catch {
+ return
+ }
+ guard let self else { return }
+ await self.refreshAutomaticDownloads()
+ }
+ }
+ }
+
+ func refreshAutomaticDownload(ruleID: UUID) async throws {
+ guard let rule = automaticDownloadRules.first(where: { $0.id == ruleID }) else {
+ return
+ }
+ try await refreshAutomaticDownload(
+ ruleID: ruleID,
+ allowsNewDownloads: rule.keepsUpToDate
+ )
+ }
+
+ private func refreshAutomaticDownload(
+ ruleID: UUID,
+ allowsNewDownloads: Bool
+ ) async throws {
+ guard refreshingAutomaticRuleIDs.insert(ruleID).inserted else { return }
+ defer { refreshingAutomaticRuleIDs.remove(ruleID) }
+ guard var rule = automaticDownloadRules.first(where: { $0.id == ruleID }) else {
+ return
+ }
+
+ do {
+ let authorization = try await creationStore.authorization(forLibraryID: rule.libraryID)
+ guard authorization.scope.accountID == rule.accountID,
+ authorization.scope.serverIdentifier == rule.serverIdentifier else {
+ throw PlexDownloadCreationAuthorizationError.authorizationExpired
+ }
+ let configuration = try await currentConfiguration(for: authorization)
+ let episodes = try await episodes(for: rule, using: configuration)
+
+ if rule.removesWatchedDownloads {
+ let watchedRatingKeys = Set(
+ episodes.lazy.filter(\.isWatched).map(\.ratingKey)
+ )
+ let packagesToRemove = downloadedMedia.filter {
+ $0.package.manifest.identity.serverIdentifier == rule.serverIdentifier
+ && watchedRatingKeys.contains($0.item.ratingKey)
+ }
+ for media in packagesToRemove {
+ try await remove(packageID: media.id)
+ }
+ }
+
+ var itemErrors: [String] = []
+ if allowsNewDownloads {
+ let eligibleEpisodes = episodes.filter(rule.policy.includes)
+ for episode in eligibleEpisodes {
+ if containsDownload(for: episode) || isDownloading(episode) {
+ continue
+ }
+ if let failedJob = jobs.first(where: {
+ $0.packageIdentity.serverIdentifier == rule.serverIdentifier
+ && $0.packageIdentity.ratingKey == episode.ratingKey
+ && $0.state == .failed
+ }) {
+ await retry(jobID: failedJob.id)
+ continue
+ }
+ do {
+ try await download(episode)
+ } catch {
+ itemErrors.append("\(episode.title): \(error.localizedDescription)")
+ }
+ }
+ }
+
+ rule.lastRefreshedAt = Date()
+ rule.lastErrorMessage = itemErrors.first.map {
+ itemErrors.count == 1 ? $0 : "\($0) (+\(itemErrors.count - 1) more)"
+ }
+ try await saveAutomaticDownloadRule(rule)
+ } catch {
+ rule.lastRefreshedAt = Date()
+ rule.lastErrorMessage = error.localizedDescription
+ try? await saveAutomaticDownloadRule(rule)
+ throw error
+ }
+ }
+
+ private func episodes(
+ for rule: PlexAutomaticDownloadRule,
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> [PlexMediaItem] {
+ let firstLevel = try await allMedia(
+ at: rule.sourceChildrenPath,
+ using: configuration
+ )
+ var episodes = firstLevel.filter { $0.type?.lowercased() == "episode" }
+ let seasons = firstLevel.filter { $0.type?.lowercased() == "season" }
+
+ if rule.sourceType == "season", !seasons.isEmpty {
+ throw PlexDownloadWorkflowError.invalidAutomaticDownloadHierarchy
+ }
+ for season in seasons {
+ let detailedSeason: PlexMediaItem
+ if season.childrenPath?.nilIfBlank != nil {
+ detailedSeason = season
+ } else {
+ detailedSeason = try await client.fetchMediaMetadata(
+ ratingKey: season.ratingKey,
+ using: configuration
+ )
+ }
+ guard let path = detailedSeason.childrenPath?.nilIfBlank else {
+ throw PlexDownloadWorkflowError.invalidAutomaticDownloadHierarchy
+ }
+ let children = try await allMedia(at: path, using: configuration)
+ episodes.append(contentsOf: children.filter {
+ $0.type?.lowercased() == "episode"
+ })
+ }
+
+ let unexpectedTypes = Set(firstLevel.compactMap { item -> String? in
+ guard let type = item.type?.lowercased(), type != "episode", type != "season" else {
+ return nil
+ }
+ return type
+ })
+ guard unexpectedTypes.isEmpty else {
+ throw PlexDownloadWorkflowError.invalidAutomaticDownloadHierarchy
+ }
+
+ var seen: Set = []
+ return episodes
+ .filter { seen.insert($0.ratingKey).inserted }
+ .sorted {
+ if $0.parentIndex != $1.parentIndex {
+ return ($0.parentIndex ?? 0) < ($1.parentIndex ?? 0)
+ }
+ if $0.index != $1.index {
+ return ($0.index ?? 0) < ($1.index ?? 0)
+ }
+ return $0.ratingKey.localizedStandardCompare($1.ratingKey) == .orderedAscending
+ }
+ }
+
+ private func allMedia(
+ at contentPath: String,
+ using configuration: PlexConnectionConfiguration
+ ) async throws -> [PlexMediaItem] {
+ let pageSize = 100
+ var items: [PlexMediaItem] = []
+ var seenIDs: Set = []
+ var start = 0
+
+ while true {
+ let page = try await client.fetchMediaPage(
+ contentPath: contentPath,
+ using: configuration,
+ start: start,
+ size: pageSize
+ )
+ guard !page.items.isEmpty else { break }
+ let newItems = page.items.filter { seenIDs.insert($0.id).inserted }
+ guard !newItems.isEmpty else { break }
+ items.append(contentsOf: newItems)
+ start += page.items.count
+ if let totalSize = page.totalSize, start >= totalSize {
+ break
+ }
+ if page.totalSize == nil, page.items.count < pageSize {
+ break
+ }
+ }
+ return items
+ }
+
+ private func saveAutomaticDownloadRule(
+ _ rule: PlexAutomaticDownloadRule
+ ) async throws {
+ try await automaticRuleRegistry.save(rule)
+ if let index = automaticDownloadRules.firstIndex(where: { $0.id == rule.id }) {
+ automaticDownloadRules[index] = rule
+ } else {
+ automaticDownloadRules.append(rule)
+ }
+ sortAutomaticDownloadRules()
+ }
+
+ private func sortAutomaticDownloadRules() {
+ automaticDownloadRules.sort {
+ $0.title.localizedStandardCompare($1.title) == .orderedAscending
+ }
+ }
+
+ func cancel(jobID: UUID) async {
+ jobTasks[jobID]?.cancel()
+ jobTasks[jobID] = nil
+ restartPreparationJobIDs.remove(jobID)
+ guard let job = jobs.first(where: { $0.id == jobID }) else { return }
+ if let transferID = job.transferID {
+ try? await transferCoordinator.cancel(transferID: transferID)
+ }
+ await deleteServerQueueItemIfPossible(job)
+ try? await jobRegistry.remove(withID: jobID)
+ try? await preparedAssetStore.remove(packageID: jobID)
+ jobs.removeAll { $0.id == jobID }
+ transferProgress[jobID] = nil
+ startPendingJobs()
+ }
+
+ func pause(jobID: UUID) async {
+ guard var job = jobs.first(where: { $0.id == jobID }),
+ job.state == .transferring,
+ let transferID = job.transferID else {
+ return
+ }
+ do {
+ try await transferCoordinator.pause(transferID: transferID)
+ jobTasks[jobID]?.cancel()
+ jobTasks[jobID] = nil
+ job.state = .paused
+ job.updatedAt = Date()
+ try await save(job)
+ startPendingJobs()
+ } catch {
+ await fail(jobID: jobID, message: error.localizedDescription)
+ }
+ }
+
+ func resume(jobID: UUID) async {
+ guard var job = jobs.first(where: { $0.id == jobID }),
+ job.state == .paused,
+ let transferID = job.transferID else {
+ return
+ }
+ do {
+ try await transferCoordinator.resume(transferID: transferID)
+ job.state = .transferring
+ job.errorMessage = nil
+ job.updatedAt = Date()
+ try await save(job)
+ startPendingJobs()
+ } catch {
+ await fail(jobID: jobID, message: error.localizedDescription)
+ }
+ }
+
+ func retry(jobID: UUID) async {
+ guard var job = jobs.first(where: { $0.id == jobID }), job.state == .failed else {
+ return
+ }
+ if let transferID = job.transferID {
+ try? await transferCoordinator.cancel(transferID: transferID)
+ }
+ job.state = .waitingForServer
+ job.transferID = nil
+ job.errorMessage = nil
+ job.serverPreparationProgress = nil
+ job.updatedAt = Date()
+ do {
+ try await save(job)
+ restartPreparationJobIDs.insert(job.id)
+ startPendingJobs()
+ } catch {
+ await fail(jobID: jobID, message: error.localizedDescription)
+ }
+ }
+
+ func remove(packageID: UUID) async throws {
+ try await packageStore.removePackage(withID: packageID)
+ try await playbackRegistry.remove(packageID: packageID)
+ downloadedMedia.removeAll { $0.id == packageID }
+ syncErrorMessages[packageID] = nil
+ }
+
+ func playbackPresentation(for media: PlexOfflineMedia) async throws -> PlexPlaybackPresentation {
+ guard let scope = currentAccountScope,
+ let currentMedia = try await packageStore.offlineMedia(
+ withID: media.id,
+ accountID: scope.accountID,
+ serverIdentifier: scope.serverIdentifier
+ ),
+ let source = currentMedia.item.defaultPlaybackSource else {
+ throw PlexDownloadWorkflowError.unavailableOfflineMedia
+ }
+ if let index = downloadedMedia.firstIndex(where: { $0.id == currentMedia.id }) {
+ downloadedMedia[index] = currentMedia
+ }
+ let record = try await playbackRegistry.record(for: currentMedia.id)
+ let duration = currentMedia.item.duration.map { TimeInterval($0) / 1_000 }
+ let startTime = record.map { TimeInterval($0.position) / 1_000 }
+ ?? currentMedia.item.viewOffset.map { TimeInterval($0) / 1_000 }
+ ?? 0
+ let mediaKind = PlexPlaybackMediaKind(media: currentMedia.item.media[source.mediaIndex])
+ let plan = PlexPlaybackPlan(
+ url: currentMedia.package.mediaURL,
+ method: .directPlay,
+ mediaKind: mediaKind,
+ sessionIdentifier: UUID().uuidString,
+ ratingKey: currentMedia.item.ratingKey,
+ duration: duration,
+ startTime: max(startTime, 0),
+ source: source,
+ usesServerMediaSelection: false
+ )
+ return PlexPlaybackPresentation(
+ item: currentMedia.item,
+ plan: plan,
+ queue: nil,
+ videoQuality: .original,
+ serverIdentifier: currentMedia.package.manifest.identity.serverIdentifier,
+ offlinePackageID: currentMedia.id
+ )
+ }
+
+ func recordOfflineTimeline(
+ packageID: UUID,
+ update: PlexTimelineUpdate
+ ) async -> PlexTimelineResponse? {
+ guard let media = downloadedMedia.first(where: { $0.id == packageID }) else {
+ return nil
+ }
+ do {
+ var record = try await playbackRegistry.record(for: packageID)
+ ?? PlexOfflinePlaybackRecord(
+ packageID: packageID,
+ accountID: media.package.manifest.identity.accountID,
+ serverIdentifier: media.package.manifest.identity.serverIdentifier,
+ ratingKey: media.item.ratingKey,
+ baselineViewOffset: media.item.viewOffset,
+ baselineViewCount: media.item.viewCount,
+ position: 0,
+ duration: max(update.duration, 0),
+ state: update.state,
+ updatedAt: Date(),
+ needsSync: false
+ )
+ record.position = max(update.time, 0)
+ record.duration = max(update.duration, 0)
+ record.state = update.state
+ record.updatedAt = Date()
+ record.needsSync = true
+ try await playbackRegistry.save(record)
+ return await synchronize(record)
+ } catch {
+ syncErrorMessages[packageID] = error.localizedDescription
+ return nil
+ }
+ }
+
+ func synchronizeOfflineProgress() async {
+ guard let records = try? await playbackRegistry.records() else { return }
+ for record in records where record.needsSync {
+ _ = await synchronize(record)
+ }
+ }
+
+ private func begin(jobID: UUID, restartingServerPreparation: Bool) {
+ guard jobTasks[jobID] == nil,
+ jobTasks.count < maximumConcurrentJobs else {
+ if restartingServerPreparation {
+ restartPreparationJobIDs.insert(jobID)
+ }
+ return
+ }
+ restartPreparationJobIDs.remove(jobID)
+ jobTasks[jobID] = Task { [weak self] in
+ guard let self else { return }
+ defer {
+ jobTasks[jobID] = nil
+ startPendingJobs()
+ }
+ do {
+ try await run(jobID: jobID, restartingServerPreparation: restartingServerPreparation)
+ } catch is CancellationError {
+ return
+ } catch PlexDownloadWorkflowError.serverChanged {
+ await fail(
+ jobID: jobID,
+ message: PlexDownloadWorkflowError.serverChanged.localizedDescription
+ )
+ } catch {
+ await fail(jobID: jobID, message: error.localizedDescription)
+ }
+ }
+ }
+
+ private func startPendingJobs() {
+ let availableSlots = maximumConcurrentJobs - jobTasks.count
+ guard availableSlots > 0 else { return }
+ let pending = jobs
+ .filter {
+ $0.state != .failed
+ && $0.state != .paused
+ && jobTasks[$0.id] == nil
+ }
+ .sorted {
+ if $0.createdAt != $1.createdAt {
+ return $0.createdAt < $1.createdAt
+ }
+ return $0.id.uuidString < $1.id.uuidString
+ }
+ .prefix(availableSlots)
+ for job in pending {
+ begin(
+ jobID: job.id,
+ restartingServerPreparation: restartPreparationJobIDs.contains(job.id)
+ )
+ }
+ }
+
+ private func run(jobID: UUID, restartingServerPreparation: Bool) async throws {
+ guard let job = jobs.first(where: { $0.id == jobID }) else { return }
+ if job.state == .transferring, job.transferID != nil {
+ try await monitorTransfer(jobID: jobID)
+ return
+ }
+ try await prepare(jobID: jobID, restartingServerPreparation: restartingServerPreparation)
+ }
+
+ private func prepare(jobID: UUID, restartingServerPreparation: Bool) async throws {
+ var didRestart = false
+ while !Task.isCancelled {
+ guard var job = jobs.first(where: { $0.id == jobID }) else { return }
+ let configuration = try await currentConfiguration(for: job)
+ let queueItems = try await client.fetchDownloadQueueItems(
+ queueID: job.packageIdentity.queueID,
+ itemIDs: [job.packageIdentity.queueItemID],
+ using: configuration
+ )
+ guard queueItems.count == 1,
+ let queueItem = queueItems.first,
+ queueItem.id == job.packageIdentity.queueItemID,
+ queueItem.queueID == job.packageIdentity.queueID,
+ queueItem.key == job.packageIdentity.metadataKey else {
+ throw PlexDownloadWorkflowError.invalidQueueResponse
+ }
+
+ job.serverPreparationProgress = queueItem.transcodeSession?.progress.map {
+ min(max($0 / 100, 0), 1)
+ }
+ job.errorMessage = nil
+ job.updatedAt = Date()
+ try await save(job)
+
+ switch queueItem.status {
+ case .available:
+ try await scheduleTransfer(for: job, configuration: configuration)
+ try await monitorTransfer(jobID: jobID)
+ return
+ case .error, .expired:
+ guard restartingServerPreparation, !didRestart else {
+ let message = queueItem.failureDescription
+ ?? "Plex could not prepare this item for download."
+ throw PlexDownloadWorkflowError.serverPreparationFailed(message)
+ }
+ try await client.restartDownloadQueueItems(
+ queueID: job.packageIdentity.queueID,
+ itemIDs: [job.packageIdentity.queueItemID],
+ using: configuration
+ )
+ didRestart = true
+ case .deciding, .waiting, .processing:
+ break
+ }
+ try await Task.sleep(for: pollingInterval)
+ }
+ throw CancellationError()
+ }
+
+ private func scheduleTransfer(
+ for job: PlexDownloadJob,
+ configuration: PlexConnectionConfiguration
+ ) async throws {
+ let document = try await client.fetchDownloadQueueDecisionDocument(
+ queueID: job.packageIdentity.queueID,
+ itemID: job.packageIdentity.queueItemID,
+ using: configuration
+ )
+ let request = try client.downloadQueueMediaRequest(
+ queueID: job.packageIdentity.queueID,
+ itemID: job.packageIdentity.queueItemID,
+ using: configuration
+ )
+ let transferID = UUID()
+ let transferRequest = PlexDownloadTransferRequest(
+ packageIdentity: job.packageIdentity,
+ title: job.title,
+ mediaType: job.mediaType,
+ decisionData: document.data,
+ mediaFileExtension: nil,
+ contentType: nil,
+ request: request
+ )
+ _ = try await creationStore.schedule(
+ transferRequest,
+ forLibraryID: job.libraryID,
+ transferID: transferID
+ )
+ var updated = job
+ updated.state = .transferring
+ updated.serverPreparationProgress = 1
+ updated.transferID = transferID
+ updated.errorMessage = nil
+ updated.updatedAt = Date()
+ try await save(updated)
+ }
+
+ private func monitorTransfer(jobID: UUID) async throws {
+ while !Task.isCancelled {
+ guard let job = jobs.first(where: { $0.id == jobID }),
+ let transferID = job.transferID else {
+ return
+ }
+ if try await packageStore.package(withID: job.packageIdentity.packageID) != nil {
+ await finish(job)
+ return
+ }
+ let records = try await transferCoordinator.records()
+ guard let record = records.first(where: { $0.id == transferID }) else {
+ throw PlexDownloadWorkflowError.unavailableOfflineMedia
+ }
+ if record.state == .failed {
+ let message = record.failure.map { "Download failed (\($0.rawValue))." }
+ ?? "The background download failed."
+ throw PlexDownloadWorkflowError.serverPreparationFailed(message)
+ }
+ if record.state == .paused {
+ var paused = job
+ paused.state = .paused
+ paused.updatedAt = Date()
+ try await save(paused)
+ return
+ }
+ if let progress = await transferCoordinator.progress(for: transferID) {
+ transferProgress[jobID] = progress
+ }
+ try await Task.sleep(for: .milliseconds(500))
+ }
+ throw CancellationError()
+ }
+
+ private func finish(_ job: PlexDownloadJob) async {
+ if let artworkData = try? await preparedAssetStore.artwork(packageID: job.id) {
+ _ = try? await packageStore.installArtwork(artworkData, for: job.id)
+ }
+ try? await preparedAssetStore.remove(packageID: job.id)
+ await deleteServerQueueItemIfPossible(job)
+ try? await jobRegistry.remove(withID: job.id)
+ jobs.removeAll { $0.id == job.id }
+ transferProgress[job.id] = nil
+ do {
+ downloadedMedia = try await accountScopedDownloadedMedia()
+ } catch {
+ startupErrorMessage = error.localizedDescription
+ }
+ }
+
+ private func reconcileWorkflowState() async throws {
+ let transfers = try await transferCoordinator.records()
+ for job in jobs {
+ if try await packageStore.package(withID: job.id) != nil {
+ await finish(job)
+ continue
+ }
+ if let transfer = transfers.first(where: {
+ $0.packageIdentity.packageID == job.packageIdentity.packageID
+ }) {
+ var recovered = job
+ recovered.transferID = transfer.id
+ recovered.updatedAt = Date()
+ switch transfer.state {
+ case .paused:
+ recovered.state = .paused
+ recovered.errorMessage = nil
+ case .failed:
+ recovered.state = .failed
+ recovered.errorMessage = transfer.failure.map {
+ "Download failed (\($0.rawValue))."
+ } ?? "The background download failed."
+ case .scheduled, .transferring, .downloaded, .publishing:
+ recovered.state = .transferring
+ recovered.errorMessage = nil
+ }
+ try await save(recovered)
+ } else if job.state == .transferring || job.state == .paused {
+ var missing = job
+ missing.state = .failed
+ missing.errorMessage = "The background download task is no longer available."
+ missing.updatedAt = Date()
+ try await save(missing)
+ }
+ }
+ }
+
+ private func fail(jobID: UUID, message: String) async {
+ guard var job = jobs.first(where: { $0.id == jobID }) else { return }
+ job.state = .failed
+ job.errorMessage = message
+ job.updatedAt = Date()
+ try? await save(job)
+ transferProgress[jobID] = nil
+ }
+
+ private func save(_ job: PlexDownloadJob) async throws {
+ try await jobRegistry.save(job)
+ if let index = jobs.firstIndex(where: { $0.id == job.id }) {
+ jobs[index] = job
+ } else {
+ jobs.append(job)
+ }
+ jobs.sort { $0.createdAt < $1.createdAt }
+ }
+
+ private func currentConfiguration(
+ for job: PlexDownloadJob
+ ) async throws -> PlexConnectionConfiguration {
+ let configuration = try await connectionStore.currentConfiguration()
+ guard job.packageIdentity.accountID == job.accountID,
+ configuration.serverIdentifier?.nilIfBlank == job.packageIdentity.serverIdentifier,
+ authStore.authenticatedUser?.id == job.accountID else {
+ throw PlexDownloadWorkflowError.serverChanged
+ }
+ return configuration
+ }
+
+ private func currentConfiguration(
+ for authorization: PlexDownloadCreationAuthorization
+ ) async throws -> PlexConnectionConfiguration {
+ let configuration = try await connectionStore.currentConfiguration()
+ guard configuration.serverIdentifier?.nilIfBlank == authorization.scope.serverIdentifier,
+ creationStore.isCurrent(authorization) else {
+ throw PlexDownloadCreationAuthorizationError.authorizationExpired
+ }
+ return configuration
+ }
+
+ private func deleteServerQueueItemIfPossible(_ job: PlexDownloadJob) async {
+ guard let configuration = try? await currentConfiguration(for: job) else { return }
+ try? await client.deleteDownloadQueueItems(
+ queueID: job.packageIdentity.queueID,
+ itemIDs: [job.packageIdentity.queueItemID],
+ using: configuration
+ )
+ }
+
+ private func synchronize(
+ _ originalRecord: PlexOfflinePlaybackRecord
+ ) async -> PlexTimelineResponse? {
+ guard originalRecord.needsSync else { return nil }
+ do {
+ let configuration = try await connectionStore.currentConfiguration()
+ guard let accountID = originalRecord.accountID,
+ authStore.authenticatedUser?.id == accountID,
+ configuration.serverIdentifier?.nilIfBlank == originalRecord.serverIdentifier else {
+ return nil
+ }
+ let serverItem = try await client.fetchMediaMetadata(
+ ratingKey: originalRecord.ratingKey,
+ using: configuration
+ )
+ guard Self.normalizedOffset(serverItem.viewOffset)
+ == Self.normalizedOffset(originalRecord.baselineViewOffset),
+ Self.normalizedCount(serverItem.viewCount)
+ == Self.normalizedCount(originalRecord.baselineViewCount) else {
+ syncErrorMessages[originalRecord.packageID] = PlexDownloadWorkflowError
+ .staleOfflineProgress.localizedDescription
+ return nil
+ }
+ let endpoints = try await browserStore.downloadProviderEndpoints(using: configuration)
+ guard let timelinePath = endpoints.timelinePath else {
+ throw PlexAPIError.missingLibraryTimelineFeature
+ }
+ let update = PlexTimelineUpdate(
+ ratingKey: originalRecord.ratingKey,
+ state: originalRecord.state,
+ time: originalRecord.position,
+ duration: originalRecord.duration,
+ sessionIdentifier: UUID().uuidString,
+ continuing: false,
+ offline: true
+ )
+ let response = try await client.reportTimeline(
+ update,
+ endpointPath: timelinePath,
+ using: configuration
+ )
+ let synchronizedItem = try await client.fetchMediaMetadata(
+ ratingKey: originalRecord.ratingKey,
+ using: configuration
+ )
+ var synced = originalRecord
+ synced.baselineViewOffset = synchronizedItem.viewOffset
+ synced.baselineViewCount = synchronizedItem.viewCount
+ synced.needsSync = false
+ try await playbackRegistry.save(synced)
+ syncErrorMessages[originalRecord.packageID] = nil
+ return response
+ } catch {
+ syncErrorMessages[originalRecord.packageID] = error.localizedDescription
+ return nil
+ }
+ }
+
+ private var currentServerIdentifier: String? {
+ connectionStore.activeConnection?.serverID
+ ?? connectionStore.settings.selectedServerIdentifier?.nilIfBlank
+ }
+
+ private struct AccountScope: Equatable {
+ let accountID: Int
+ let serverIdentifier: String
+ }
+
+ private var currentAccountScope: AccountScope? {
+ guard let accountID = authStore.authenticatedUser?.id,
+ accountID > 0,
+ let serverIdentifier = currentServerIdentifier?.nilIfBlank else {
+ return nil
+ }
+ return AccountScope(accountID: accountID, serverIdentifier: serverIdentifier)
+ }
+
+ private func loadAccountScopedState() async throws {
+ let scope = currentAccountScope
+ if scope != loadedAccountScope {
+ for task in jobTasks.values {
+ task.cancel()
+ }
+ jobTasks.removeAll()
+ restartPreparationJobIDs.removeAll()
+ transferProgress.removeAll()
+ syncErrorMessages.removeAll()
+ loadedAccountScope = scope
+ }
+
+ guard let scope else {
+ jobs = []
+ downloadedMedia = []
+ automaticDownloadRules = []
+ return
+ }
+ jobs = try await jobRegistry.jobs().filter {
+ $0.accountID == scope.accountID
+ && $0.packageIdentity.accountID == scope.accountID
+ && $0.packageIdentity.serverIdentifier == scope.serverIdentifier
+ }
+ downloadedMedia = try await accountScopedDownloadedMedia(scope: scope)
+ automaticDownloadRules = try await automaticRuleRegistry.rules().filter {
+ $0.accountID == scope.accountID
+ && $0.serverIdentifier == scope.serverIdentifier
+ }
+ }
+
+ private func accountScopedDownloadedMedia(
+ scope explicitScope: AccountScope? = nil
+ ) async throws -> [PlexOfflineMedia] {
+ guard let scope = explicitScope ?? currentAccountScope else { return [] }
+ return try await packageStore.offlineMedia(
+ accountID: scope.accountID,
+ serverIdentifier: scope.serverIdentifier
+ )
+ }
+
+ private static func normalizedOffset(_ value: Int?) -> Int {
+ max(value ?? 0, 0)
+ }
+
+ private static func normalizedCount(_ value: Int?) -> Int {
+ max(value ?? 0, 0)
+ }
+
+}
+
+extension PlexMediaItem {
+ var supportsOfflineDownload: Bool {
+ guard isPlayable, let type = type?.lowercased() else { return false }
+ return ["movie", "episode", "track"].contains(type)
+ }
+
+ var supportsAutomaticOfflineDownloads: Bool {
+ guard let type = type?.lowercased() else { return false }
+ return type == "show" || type == "season"
+ }
+}
diff --git a/PlexBar/Stores/PlexGlobalSearchStore.swift b/PlexBar/Stores/PlexGlobalSearchStore.swift
new file mode 100644
index 0000000..3fce36b
--- /dev/null
+++ b/PlexBar/Stores/PlexGlobalSearchStore.swift
@@ -0,0 +1,322 @@
+import PlexModels
+import Foundation
+import Observation
+
+@MainActor
+@Observable
+final class PlexGlobalSearchStore {
+ var text = ""
+ var navigationPath: [PlexNavigationRoute] = []
+ private(set) var displayedQuery = ""
+ private(set) var pendingQuery: String?
+ private(set) var hubs: [PlexHub] = []
+ private(set) var hasSearched = false
+ private(set) var errorMessage: String?
+ private(set) var itemsByHubPath: [String: [PlexMediaItem]] = [:]
+ private(set) var totalSizesByHubPath: [String: Int] = [:]
+ private(set) var loadedHubPaths: Set = []
+ private(set) var loadingHubPaths: Set = []
+ private(set) var errorMessagesByHubPath: [String: String] = [:]
+
+ private let debounceDuration: Duration
+ private let pageSize: Int
+ private var requestRevision = 0
+ private var expandedContentRevision = 0
+
+ init(
+ debounceDuration: Duration = .milliseconds(300),
+ pageSize: Int = 100
+ ) {
+ self.debounceDuration = debounceDuration
+ self.pageSize = max(pageSize, 1)
+ }
+
+ var normalizedQuery: String {
+ text.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ var visibleHubs: [PlexHub] {
+ hubs.filter { !$0.metadata.isEmpty }
+ }
+
+ var isSearching: Bool {
+ pendingQuery != nil
+ }
+
+ func update(
+ forceRefresh: Bool = false,
+ load: (String) async throws -> [PlexHub]
+ ) async {
+ let requestedQuery = normalizedQuery
+ requestRevision += 1
+ let revision = requestRevision
+
+ guard !requestedQuery.isEmpty else {
+ clearContent()
+ return
+ }
+
+ if !forceRefresh,
+ hasSearched,
+ displayedQuery == requestedQuery,
+ errorMessage == nil {
+ return
+ }
+
+ pendingQuery = requestedQuery
+ errorMessage = nil
+
+ if !forceRefresh, requestedQuery != displayedQuery {
+ do {
+ try await Task.sleep(for: debounceDuration)
+ } catch {
+ finishRequest(revision: revision, query: requestedQuery)
+ return
+ }
+ }
+
+ guard requestRevision == revision,
+ normalizedQuery == requestedQuery,
+ !Task.isCancelled else {
+ finishRequest(revision: revision, query: requestedQuery)
+ return
+ }
+
+ do {
+ let loadedHubs = try await load(requestedQuery)
+ guard requestRevision == revision,
+ normalizedQuery == requestedQuery,
+ !Task.isCancelled else {
+ finishRequest(revision: revision, query: requestedQuery)
+ return
+ }
+
+ if displayedQuery != requestedQuery {
+ navigationPath.removeAll()
+ clearExpandedHubContent()
+ }
+ hubs = loadedHubs.filter { !$0.metadata.isEmpty }
+ displayedQuery = requestedQuery
+ hasSearched = true
+ errorMessage = nil
+ } catch {
+ guard requestRevision == revision,
+ normalizedQuery == requestedQuery,
+ !Task.isCancelled else {
+ finishRequest(revision: revision, query: requestedQuery)
+ return
+ }
+
+ if hubs.isEmpty {
+ displayedQuery = requestedQuery
+ }
+ hasSearched = true
+ errorMessage = error.localizedDescription
+ }
+
+ finishRequest(revision: revision, query: requestedQuery)
+ }
+
+ func reset() {
+ requestRevision += 1
+ text = ""
+ navigationPath.removeAll()
+ clearContent()
+ }
+
+ func hub(for route: PlexSearchHubRoute) -> PlexHub? {
+ hubs.first { route.matches($0, query: displayedQuery) }
+ }
+
+ func items(in hub: PlexHub) -> [PlexMediaItem] {
+ guard let path = validPath(for: hub) else {
+ return hub.metadata
+ }
+ return itemsByHubPath[path] ?? hub.metadata
+ }
+
+ func isLoadingItems(in hub: PlexHub) -> Bool {
+ guard let path = validPath(for: hub) else {
+ return false
+ }
+ return loadingHubPaths.contains(path)
+ }
+
+ func itemsErrorMessage(in hub: PlexHub) -> String? {
+ guard let path = validPath(for: hub) else {
+ return nil
+ }
+ return errorMessagesByHubPath[path]
+ }
+
+ func hasMoreItems(in hub: PlexHub) -> Bool {
+ guard let path = validPath(for: hub) else {
+ return false
+ }
+ if !loadedHubPaths.contains(path) {
+ let advertisedTotal = hub.totalSize ?? hub.size ?? hub.metadata.count
+ return hub.more || advertisedTotal > hub.metadata.count
+ }
+ let items = itemsByHubPath[path] ?? []
+ let totalSize = totalSizesByHubPath[path] ?? items.count
+ return items.count < totalSize
+ }
+
+ func loadItems(
+ in hub: PlexHub,
+ forceRefresh: Bool = false,
+ loadPage: (String, Int, Int) async throws -> PlexMediaPage
+ ) async {
+ guard let path = validPath(for: hub),
+ !loadingHubPaths.contains(path) else {
+ return
+ }
+ if loadedHubPaths.contains(path), !forceRefresh {
+ return
+ }
+
+ loadingHubPaths.insert(path)
+ let revision = expandedContentRevision
+ defer {
+ if expandedContentRevision == revision {
+ loadingHubPaths.remove(path)
+ }
+ }
+
+ do {
+ let page = try await loadPage(path, 0, pageSize)
+ guard expandedContentRevision == revision else {
+ return
+ }
+ itemsByHubPath[path] = page.items
+ totalSizesByHubPath[path] = page.totalSize ?? hub.totalSize ?? page.items.count
+ loadedHubPaths.insert(path)
+ errorMessagesByHubPath[path] = nil
+ } catch {
+ guard expandedContentRevision == revision,
+ !Task.isCancelled else {
+ return
+ }
+ errorMessagesByHubPath[path] = error.localizedDescription
+ }
+ }
+
+ func loadMoreItemsIfNeeded(
+ in hub: PlexHub,
+ currentItem: PlexMediaItem,
+ loadPage: (String, Int, Int) async throws -> PlexMediaPage
+ ) async {
+ guard let path = validPath(for: hub),
+ itemsByHubPath[path]?.last?.id == currentItem.id,
+ hasMoreItems(in: hub),
+ !loadingHubPaths.contains(path) else {
+ return
+ }
+
+ loadingHubPaths.insert(path)
+ let revision = expandedContentRevision
+ defer {
+ if expandedContentRevision == revision {
+ loadingHubPaths.remove(path)
+ }
+ }
+
+ do {
+ let existingItems = itemsByHubPath[path] ?? []
+ let page = try await loadPage(path, existingItems.count, pageSize)
+ guard expandedContentRevision == revision else {
+ return
+ }
+ let existingIDs = Set(existingItems.map(\.id))
+ itemsByHubPath[path] = existingItems
+ + page.items.filter { !existingIDs.contains($0.id) }
+ totalSizesByHubPath[path] = page.totalSize
+ ?? totalSizesByHubPath[path]
+ ?? hub.totalSize
+ ?? page.items.count
+ errorMessagesByHubPath[path] = nil
+ } catch {
+ guard expandedContentRevision == revision,
+ !Task.isCancelled else {
+ return
+ }
+ errorMessagesByHubPath[path] = error.localizedDescription
+ }
+ }
+
+ func replaceCachedWatchedState(with refreshedItem: PlexMediaItem) {
+ hubs = hubs.map { hub in
+ var updatedHub = hub
+ updatedHub.metadata = hub.metadata.map { item in
+ guard item.ratingKey == refreshedItem.ratingKey else {
+ return item
+ }
+ return item.mergingWatchedState(from: refreshedItem)
+ }
+ return updatedHub
+ }
+ itemsByHubPath = itemsByHubPath.mapValues { items in
+ items.map { item in
+ guard item.ratingKey == refreshedItem.ratingKey else {
+ return item
+ }
+ return item.mergingWatchedState(from: refreshedItem)
+ }
+ }
+ }
+
+ func replaceCachedUserRating(with refreshedItem: PlexMediaItem) {
+ hubs = hubs.map { hub in
+ var updatedHub = hub
+ updatedHub.metadata = hub.metadata.map { item in
+ guard item.ratingKey == refreshedItem.ratingKey else {
+ return item
+ }
+ return item.mergingUserRating(from: refreshedItem)
+ }
+ return updatedHub
+ }
+ itemsByHubPath = itemsByHubPath.mapValues { items in
+ items.map { item in
+ guard item.ratingKey == refreshedItem.ratingKey else {
+ return item
+ }
+ return item.mergingUserRating(from: refreshedItem)
+ }
+ }
+ }
+
+ private func clearContent() {
+ navigationPath.removeAll()
+ displayedQuery = ""
+ pendingQuery = nil
+ hubs = []
+ hasSearched = false
+ errorMessage = nil
+ clearExpandedHubContent()
+ }
+
+ private func clearExpandedHubContent() {
+ expandedContentRevision += 1
+ itemsByHubPath = [:]
+ totalSizesByHubPath = [:]
+ loadedHubPaths = []
+ loadingHubPaths = []
+ errorMessagesByHubPath = [:]
+ }
+
+ private func validPath(for hub: PlexHub) -> String? {
+ guard let path = hub.key,
+ !path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ return nil
+ }
+ return path
+ }
+
+ private func finishRequest(revision: Int, query: String) {
+ guard requestRevision == revision, pendingQuery == query else {
+ return
+ }
+ pendingQuery = nil
+ }
+}
diff --git a/PlexBar/Stores/PlexHistoryStore.swift b/PlexBar/Stores/PlexHistoryStore.swift
new file mode 100644
index 0000000..ff1225c
--- /dev/null
+++ b/PlexBar/Stores/PlexHistoryStore.swift
@@ -0,0 +1,354 @@
+import PlexModels
+import Foundation
+import Observation
+
+struct PlexMediaHistoryPresentation {
+ fileprivate(set) var items: [PlexHistoryItem] = []
+ fileprivate(set) var isLoading = false
+ fileprivate(set) var errorMessage: String?
+ fileprivate(set) var hasLoaded = false
+
+ var isVisible: Bool {
+ isLoading || errorMessage != nil || !items.isEmpty
+ }
+}
+
+@MainActor
+@Observable
+final class PlexHistoryStore {
+ static let historyWindowDays = 30
+ private static let mediaHistoryCacheLimit = 12
+
+ private let connectionStore: PlexConnectionStore
+ private let libraryStore: PlexLibraryStore
+ private let client: PlexAPIClient
+ private var pollingTask: Task?
+ private var mediaHistoryByKey: [MediaHistoryCacheKey: PlexMediaHistoryPresentation] = [:]
+ private var mediaHistoryGenerations: [MediaHistoryCacheKey: Int] = [:]
+ private var mediaHistoryRecency: [MediaHistoryCacheKey] = []
+ private var identityDirectoryAccountScope: String?
+ private var refreshGeneration = UUID()
+
+ var recentItems: [PlexHistoryItem] = []
+ var seriesByEpisodeID: [String: PlexHistorySeriesIdentity] = [:]
+ var accountsByID: [Int: PlexAccount] = [:]
+ var devicesByID: [Int: PlexHistoryDevice] = [:]
+ var isLoading = false
+ var errorMessage: String?
+ var lastUpdated: Date?
+
+ init(
+ connectionStore: PlexConnectionStore,
+ libraryStore: PlexLibraryStore,
+ client: PlexAPIClient = PlexAPIClient(),
+ startsPolling: Bool = true
+ ) {
+ self.connectionStore = connectionStore
+ self.libraryStore = libraryStore
+ self.client = client
+ if startsPolling {
+ startPolling()
+ }
+ }
+
+ var topTitleEntries: [PlexTopChartEntry] {
+ PlexHistoryAnalytics.topTitleEntries(
+ from: recentItems,
+ accountsByID: accountsByID,
+ seriesByEpisodeID: seriesByEpisodeID,
+ limit: 5
+ )
+ }
+
+ var topTypeEntries: [PlexTopChartEntry] {
+ PlexHistoryAnalytics.topTypeEntries(from: recentItems, accountsByID: accountsByID, limit: 4)
+ }
+
+ var topUserEntries: [PlexUserActivityEntry] {
+ PlexHistoryAnalytics.topUserEntries(from: recentItems, accountsByID: accountsByID, limit: 5)
+ }
+
+ var recentViewerEntries: [PlexUserActivityEntry] {
+ PlexHistoryAnalytics.recentViewerEntries(
+ from: recentItems, accountsByID: accountsByID, limit: 6)
+ }
+
+ var distinctViewerCount: Int {
+ Set(recentItems.compactMap(\.accountID)).count
+ }
+
+ var totalPlayCount: Int {
+ recentItems.count
+ }
+
+ var historyWindowLabel: String {
+ "Last \(Self.historyWindowDays) days"
+ }
+
+ func refreshNow() {
+ Task {
+ await refresh()
+ libraryStore.refreshNow()
+ }
+ }
+
+ func restartPolling() {
+ pollingTask?.cancel()
+ pollingTask = nil
+ startPolling()
+ }
+
+ func resetServerScopedState() {
+ refreshGeneration = UUID()
+ recentItems = []
+ seriesByEpisodeID = [:]
+ accountsByID = [:]
+ devicesByID = [:]
+ identityDirectoryAccountScope = nil
+ mediaHistoryByKey = [:]
+ mediaHistoryGenerations = [:]
+ mediaHistoryRecency = []
+ errorMessage = nil
+ isLoading = false
+ lastUpdated = nil
+ }
+
+ func mediaHistoryPresentation(for item: PlexMediaItem) -> PlexMediaHistoryPresentation? {
+ guard let key = mediaHistoryCacheKey(for: item) else {
+ return nil
+ }
+
+ return mediaHistoryByKey[key] ?? PlexMediaHistoryPresentation()
+ }
+
+ func loadMediaHistory(
+ for item: PlexMediaItem,
+ forceRefresh: Bool = false
+ ) async {
+ guard let key = mediaHistoryCacheKey(for: item) else {
+ return
+ }
+ prepareIdentityDirectory(for: key.accountScope)
+
+ var presentation = mediaHistoryByKey[key] ?? PlexMediaHistoryPresentation()
+ guard forceRefresh || (!presentation.hasLoaded && !presentation.isLoading) else {
+ touchMediaHistory(key)
+ return
+ }
+
+ let generation = (mediaHistoryGenerations[key] ?? 0) + 1
+ mediaHistoryGenerations[key] = generation
+ presentation.isLoading = true
+ presentation.errorMessage = nil
+ mediaHistoryByKey[key] = presentation
+ touchMediaHistory(key)
+
+ do {
+ let cutoffDate = historyCutoffDate()
+ let shouldLoadIdentityDirectory = identityDirectoryAccountScope != key.accountScope
+ let result = try await connectionStore.perform { configuration in
+ async let historyTask = client.fetchHistory(
+ using: configuration,
+ since: cutoffDate,
+ metadataItemID: key.metadataItemID
+ )
+
+ let identityDirectory: PlexHistoryIdentityDirectory?
+ if shouldLoadIdentityDirectory {
+ identityDirectory = try? await client.fetchHistoryIdentityDirectory(
+ using: configuration
+ )
+ } else {
+ identityDirectory = nil
+ }
+
+ return (try await historyTask, identityDirectory)
+ }
+
+ guard mediaHistoryGenerations[key] == generation else {
+ return
+ }
+ guard mediaHistoryCacheKey(for: item) == key else {
+ discardMediaHistory(key)
+ return
+ }
+
+ if let identityDirectory = result.1 {
+ apply(identityDirectory, accountScope: key.accountScope)
+ }
+ presentation.items = PlexHistoryAnalytics.groupedWatchItems(from: result.0)
+ presentation.isLoading = false
+ presentation.errorMessage = nil
+ presentation.hasLoaded = true
+ mediaHistoryByKey[key] = presentation
+ touchMediaHistory(key)
+ } catch {
+ guard mediaHistoryGenerations[key] == generation else {
+ return
+ }
+ guard mediaHistoryCacheKey(for: item) == key else {
+ discardMediaHistory(key)
+ return
+ }
+
+ presentation.isLoading = false
+ presentation.errorMessage =
+ (error as? LocalizedError)?.errorDescription
+ ?? error.localizedDescription
+ presentation.hasLoaded = true
+ mediaHistoryByKey[key] = presentation
+ touchMediaHistory(key)
+ }
+ }
+
+ private func startPolling() {
+ guard pollingTask == nil else {
+ return
+ }
+
+ let pollIntervalDuration = connectionStore.settings.historyPollIntervalDuration
+
+ pollingTask = Task { [weak self] in
+ while !Task.isCancelled {
+ guard let self else {
+ return
+ }
+
+ await self.refresh()
+ self.libraryStore.refreshNow()
+
+ do {
+ try await Task.sleep(for: pollIntervalDuration)
+ } catch {
+ return
+ }
+ }
+ }
+ }
+
+ private func refresh() async {
+ guard connectionStore.settings.hasValidConfiguration else {
+ resetServerScopedState()
+ return
+ }
+
+ let generation = UUID()
+ refreshGeneration = generation
+ isLoading = true
+ let selectedAccountScope = connectionStore.accountCacheScope
+ prepareIdentityDirectory(for: selectedAccountScope)
+
+ do {
+ let cutoffDate = historyCutoffDate()
+ let result = try await connectionStore.perform { configuration in
+ async let historyTask = client.fetchHistory(using: configuration, since: cutoffDate)
+ async let identityDirectoryTask = client.fetchHistoryIdentityDirectory(using: configuration)
+
+ let rawHistoryItems = try await historyTask
+ let seriesByEpisodeID = try await client.fetchHistorySeriesIdentities(
+ using: configuration,
+ episodeIDs: rawHistoryItems.compactMap(\.episodeMetadataItemID)
+ )
+
+ let identityDirectory: PlexHistoryIdentityDirectory?
+ do {
+ identityDirectory = try await identityDirectoryTask
+ } catch {
+ identityDirectory = nil
+ }
+
+ return (rawHistoryItems, seriesByEpisodeID, identityDirectory)
+ }
+
+ guard refreshGeneration == generation else {
+ return
+ }
+ self.recentItems = PlexHistoryAnalytics.groupedWatchItems(from: result.0)
+ self.seriesByEpisodeID = result.1
+ if let identityDirectory = result.2 {
+ apply(identityDirectory, accountScope: selectedAccountScope)
+ }
+
+ errorMessage = nil
+ lastUpdated = Date()
+ } catch is CancellationError {
+ return
+ } catch {
+ guard refreshGeneration == generation else {
+ return
+ }
+ errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
+ }
+
+ if refreshGeneration == generation {
+ isLoading = false
+ }
+ }
+
+ private func mediaHistoryCacheKey(for item: PlexMediaItem) -> MediaHistoryCacheKey? {
+ guard item.supportsPlaybackHistory,
+ let serverIdentifier = connectionStore.settings.selectedServerIdentifier?.nilIfBlank,
+ let metadataItemID = Int(item.ratingKey)
+ else {
+ return nil
+ }
+
+ return MediaHistoryCacheKey(
+ accountScope: PlexConnectionConfiguration.accountCacheScope(
+ serverIdentifier: serverIdentifier,
+ token: connectionStore.settings.trimmedServerToken
+ ),
+ metadataItemID: metadataItemID
+ )
+ }
+
+ private func historyCutoffDate() -> Date {
+ Calendar.current.date(
+ byAdding: .day,
+ value: -Self.historyWindowDays,
+ to: Date()
+ ) ?? Date.distantPast
+ }
+
+ private func touchMediaHistory(_ key: MediaHistoryCacheKey) {
+ mediaHistoryRecency.removeAll { $0 == key }
+ mediaHistoryRecency.append(key)
+
+ while mediaHistoryRecency.count > Self.mediaHistoryCacheLimit {
+ let evictedKey = mediaHistoryRecency.removeFirst()
+ mediaHistoryByKey.removeValue(forKey: evictedKey)
+ mediaHistoryGenerations.removeValue(forKey: evictedKey)
+ }
+ }
+
+ private func discardMediaHistory(_ key: MediaHistoryCacheKey) {
+ mediaHistoryByKey.removeValue(forKey: key)
+ mediaHistoryGenerations.removeValue(forKey: key)
+ mediaHistoryRecency.removeAll { $0 == key }
+ }
+
+ private func prepareIdentityDirectory(for accountScope: String) {
+ guard identityDirectoryAccountScope != nil,
+ identityDirectoryAccountScope != accountScope
+ else {
+ return
+ }
+ accountsByID = [:]
+ devicesByID = [:]
+ identityDirectoryAccountScope = nil
+ }
+
+ private func apply(
+ _ identityDirectory: PlexHistoryIdentityDirectory,
+ accountScope: String
+ ) {
+ accountsByID = Dictionary(uniqueKeysWithValues: identityDirectory.accounts.map { ($0.id, $0) })
+ devicesByID = Dictionary(uniqueKeysWithValues: identityDirectory.devices.map { ($0.id, $0) })
+ identityDirectoryAccountScope = accountScope
+ }
+
+ private struct MediaHistoryCacheKey: Hashable {
+ let accountScope: String
+ let metadataItemID: Int
+ }
+}
diff --git a/PlexBar/Stores/PlexLibraryPresentationStore.swift b/PlexBar/Stores/PlexLibraryPresentationStore.swift
new file mode 100644
index 0000000..2f66985
--- /dev/null
+++ b/PlexBar/Stores/PlexLibraryPresentationStore.swift
@@ -0,0 +1,33 @@
+import Observation
+import SwiftUI
+
+@MainActor
+@Observable
+final class PlexLibraryPresentationState {
+ var navigationPath: [PlexNavigationRoute] = []
+ var scrollPosition = ScrollPosition(idType: String.self, edge: .top)
+ let searchStore = PlexLibrarySearchStore()
+}
+
+@MainActor
+@Observable
+final class PlexLibraryPresentationStore {
+ private(set) var statesByLibraryID: [String: PlexLibraryPresentationState] = [:]
+
+ func state(for libraryID: String) -> PlexLibraryPresentationState? {
+ statesByLibraryID[libraryID]
+ }
+
+ func synchronize(libraryIDs: [String]) {
+ let activeLibraryIDs = Set(libraryIDs)
+ statesByLibraryID = statesByLibraryID.filter { activeLibraryIDs.contains($0.key) }
+
+ for libraryID in libraryIDs where statesByLibraryID[libraryID] == nil {
+ statesByLibraryID[libraryID] = PlexLibraryPresentationState()
+ }
+ }
+
+ func removeAll() {
+ statesByLibraryID.removeAll()
+ }
+}
diff --git a/PlexBar/Stores/PlexLibraryRequest.swift b/PlexBar/Stores/PlexLibraryRequest.swift
new file mode 100644
index 0000000..dcb2ba5
--- /dev/null
+++ b/PlexBar/Stores/PlexLibraryRequest.swift
@@ -0,0 +1,30 @@
+import Foundation
+
+struct PlexLibraryRequest: Hashable {
+ let libraryID: String
+ let searchQuery: String
+ let browseOptions: PlexLibraryBrowseOptions
+
+ init(
+ libraryID: String,
+ searchQuery: String,
+ browseOptions: PlexLibraryBrowseOptions
+ ) {
+ self.libraryID = libraryID
+ self.searchQuery = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
+ self.browseOptions = browseOptions
+ }
+
+ var isTransient: Bool {
+ !searchQuery.isEmpty || browseOptions != .default
+ }
+}
+
+struct PlexBrowserCacheMetrics: Equatable, Sendable {
+ let libraryRequestCount: Int
+ let transientLibraryRequestCount: Int
+ let libraryItemOccurrenceCount: Int
+ let uniqueLibraryItemCount: Int
+ let transientRequestCountsByLibraryID: [String: Int]
+ let transientRequestLimitPerLibrary: Int
+}
diff --git a/PlexBar/Stores/PlexLibrarySearchStore.swift b/PlexBar/Stores/PlexLibrarySearchStore.swift
new file mode 100644
index 0000000..d5d5903
--- /dev/null
+++ b/PlexBar/Stores/PlexLibrarySearchStore.swift
@@ -0,0 +1,127 @@
+import Foundation
+import Observation
+
+@MainActor
+@Observable
+final class PlexLibrarySearchStore {
+ var text = ""
+ private(set) var displayedQuery = ""
+ private(set) var pendingQuery: String?
+ private(set) var selectedOptions: PlexLibraryBrowseOptions = .default
+ private(set) var displayedOptions: PlexLibraryBrowseOptions = .default
+ private(set) var pendingOptions: PlexLibraryBrowseOptions?
+
+ private let debounceDuration: Duration
+
+ init(debounceDuration: Duration = .milliseconds(300)) {
+ self.debounceDuration = debounceDuration
+ }
+
+ var normalizedQuery: String {
+ text.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ var isSearching: Bool {
+ pendingQuery != nil
+ }
+
+ var isUpdating: Bool {
+ isSearching
+ }
+
+ func selectContentType(path: String?) {
+ selectedOptions = PlexLibraryBrowseOptions(contentTypePath: path)
+ }
+
+ func selectSort(_ sort: PlexLibrarySortDefinition?) {
+ selectedOptions.sort = sort?.selection()
+ }
+
+ func selectSortDirection(
+ _ direction: PlexLibrarySortDirection,
+ definition: PlexLibrarySortDefinition
+ ) {
+ selectedOptions.sort = definition.selection(direction: direction)
+ }
+
+ func isBooleanFilterEnabled(_ filter: PlexLibraryFilterDefinition) -> Bool {
+ selectedOptions.enabledBooleanFilterIDs.contains(filter.id)
+ }
+
+ func setBooleanFilter(_ filter: PlexLibraryFilterDefinition, isEnabled: Bool) {
+ if isEnabled {
+ selectedOptions.enabledBooleanFilterIDs.insert(filter.id)
+ } else {
+ selectedOptions.enabledBooleanFilterIDs.remove(filter.id)
+ }
+ }
+
+ func selectedValues(for filter: PlexLibraryFilterDefinition) -> [PlexLibraryFilterValue] {
+ selectedOptions.valueSelections(for: filter.id)
+ }
+
+ func setSelectedValues(
+ _ values: [PlexLibraryFilterValue],
+ for filter: PlexLibraryFilterDefinition
+ ) {
+ selectedOptions.setValueSelections(values, for: filter.id)
+ }
+
+ func selectedValueCount(for filter: PlexLibraryFilterDefinition) -> Int {
+ selectedOptions.valueSelections(for: filter.id).count
+ }
+
+ var hasSelectedFilters: Bool {
+ selectedOptions.hasFilters
+ }
+
+ func clearFilters() {
+ selectedOptions.enabledBooleanFilterIDs = []
+ selectedOptions.valueFilterSelections = []
+ }
+
+ func update(load: (String) async -> Void) async {
+ await update { query, _ in
+ await load(query)
+ }
+ }
+
+ func update(load: (String, PlexLibraryBrowseOptions) async -> Void) async {
+ let requestedQuery = normalizedQuery
+ let requestedOptions = selectedOptions
+ let tracksSearchProgress = requestedQuery != displayedQuery
+ || requestedOptions != displayedOptions
+
+ if tracksSearchProgress {
+ pendingQuery = requestedQuery
+ pendingOptions = requestedOptions
+ } else {
+ pendingQuery = nil
+ pendingOptions = nil
+ }
+ defer {
+ if tracksSearchProgress,
+ pendingQuery == requestedQuery,
+ pendingOptions == requestedOptions {
+ pendingQuery = nil
+ pendingOptions = nil
+ }
+ }
+
+ if requestedQuery != displayedQuery, !requestedQuery.isEmpty {
+ try? await Task.sleep(for: debounceDuration)
+ }
+ guard !Task.isCancelled else {
+ return
+ }
+
+ await load(requestedQuery, requestedOptions)
+ guard !Task.isCancelled,
+ normalizedQuery == requestedQuery,
+ selectedOptions == requestedOptions else {
+ return
+ }
+ displayedQuery = requestedQuery
+ displayedOptions = requestedOptions
+ }
+}
diff --git a/Sources/PlexBar/Stores/PlexLibraryStore.swift b/PlexBar/Stores/PlexLibraryStore.swift
similarity index 61%
rename from Sources/PlexBar/Stores/PlexLibraryStore.swift
rename to PlexBar/Stores/PlexLibraryStore.swift
index 093f216..9d5dbb0 100644
--- a/Sources/PlexBar/Stores/PlexLibraryStore.swift
+++ b/PlexBar/Stores/PlexLibraryStore.swift
@@ -1,3 +1,4 @@
+import PlexModels
import Foundation
import Observation
@@ -6,6 +7,7 @@ import Observation
final class PlexLibraryStore {
private let connectionStore: PlexConnectionStore
private let client: PlexAPIClient
+ private var refreshGeneration = UUID()
var libraries: [PlexLibrary] = []
var isLoading = false
@@ -33,25 +35,43 @@ final class PlexLibraryStore {
func refresh() async {
guard connectionStore.settings.hasValidConfiguration else {
- libraries = []
- errorMessage = nil
- isLoading = false
- lastUpdated = nil
+ resetServerScopedState()
return
}
+ let generation = UUID()
+ refreshGeneration = generation
isLoading = true
do {
- libraries = try await connectionStore.perform { configuration in
+ let refreshedLibraries = try await connectionStore.perform { configuration in
try await client.fetchLibraries(using: configuration)
}
+ guard refreshGeneration == generation else {
+ return
+ }
+ libraries = refreshedLibraries
errorMessage = nil
lastUpdated = Date()
+ } catch is CancellationError {
+ return
} catch {
+ guard refreshGeneration == generation else {
+ return
+ }
errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
}
+ if refreshGeneration == generation {
+ isLoading = false
+ }
+ }
+
+ func resetServerScopedState() {
+ refreshGeneration = UUID()
+ libraries = []
+ errorMessage = nil
isLoading = false
+ lastUpdated = nil
}
}
diff --git a/PlexBar/Stores/PlexMainNavigationStore.swift b/PlexBar/Stores/PlexMainNavigationStore.swift
new file mode 100644
index 0000000..83b0363
--- /dev/null
+++ b/PlexBar/Stores/PlexMainNavigationStore.swift
@@ -0,0 +1,64 @@
+import PlexModels
+import Observation
+
+@MainActor
+@Observable
+final class PlexMainNavigationStore {
+ static let windowID = "main"
+
+ var selection: PlexMainSection? = .home
+ var homeNavigationPath: [PlexNavigationRoute] = []
+ var historyNavigationPath: [PlexNavigationRoute] = []
+ var collectionsNavigationPath: [PlexNavigationRoute] = []
+ var playlistsNavigationPath: [PlexNavigationRoute] = []
+
+ func showMedia(_ item: PlexMediaItem) {
+ selection = .home
+ homeNavigationPath = [.media(PlexMediaRoute(item: item))]
+ }
+
+ func resetForServerChange() {
+ selection = .home
+ homeNavigationPath.removeAll()
+ historyNavigationPath.removeAll()
+ collectionsNavigationPath.removeAll()
+ playlistsNavigationPath.removeAll()
+ }
+}
+
+enum PlexMainSection: Hashable, Sendable {
+ case home
+ case downloads
+ case library(String)
+ case collections
+ case playlists
+ case activity
+ case history
+ case users
+
+ var title: String {
+ switch self {
+ case .home: "Home"
+ case .downloads: "Downloads"
+ case .library: "Library"
+ case .collections: "Collections"
+ case .playlists: "Playlists"
+ case .activity: "Activity"
+ case .history: "History"
+ case .users: "Users"
+ }
+ }
+
+ var systemImage: String {
+ switch self {
+ case .home: "house"
+ case .downloads: "arrow.down.circle"
+ case .library: "books.vertical"
+ case .collections: "rectangle.stack"
+ case .playlists: "music.note.list"
+ case .activity: "play.rectangle.on.rectangle"
+ case .history: "clock.arrow.circlepath"
+ case .users: "person.2"
+ }
+ }
+}
diff --git a/Sources/PlexBar/Stores/PlexServerPreviewStore.swift b/PlexBar/Stores/PlexServerPreviewStore.swift
similarity index 99%
rename from Sources/PlexBar/Stores/PlexServerPreviewStore.swift
rename to PlexBar/Stores/PlexServerPreviewStore.swift
index 0d1561d..924d725 100644
--- a/Sources/PlexBar/Stores/PlexServerPreviewStore.swift
+++ b/PlexBar/Stores/PlexServerPreviewStore.swift
@@ -1,3 +1,4 @@
+import PlexModels
import Foundation
import Observation
diff --git a/Sources/PlexBar/Stores/PlexSessionStore.swift b/PlexBar/Stores/PlexSessionStore.swift
similarity index 69%
rename from Sources/PlexBar/Stores/PlexSessionStore.swift
rename to PlexBar/Stores/PlexSessionStore.swift
index 0db7138..edf4090 100644
--- a/Sources/PlexBar/Stores/PlexSessionStore.swift
+++ b/PlexBar/Stores/PlexSessionStore.swift
@@ -1,3 +1,4 @@
+import PlexModels
import Foundation
import Observation
@@ -24,6 +25,16 @@ final class PlexSessionStore {
private let geoIPClient: PlexGeoIPClient
private let eventsClient: PlexSessionEventsClient
private let connectionRecheckSleep: ConnectionRecheckSleep
+ private let activityClock: PlexActivityRefreshClock
+ private static let activityRefreshInterval: Duration = .seconds(10)
+ private var activityConsumers: Set = []
+ private var activityRefreshTask: Task?
+ private var activityRefreshID = UUID()
+ private var lastHydratedInstant: ContinuousClock.Instant?
+ private var isSystemAsleep = false
+ private var hydration: (id: UUID, scope: String, url: URL, task: Task)?
+ private var hydrationNotifications: [PlexPlaySessionStateNotification] = []
+ private var hydrationNeedsFollowup = false
private var monitorTask: Task?
private var connectionRecheckTask: Task?
private var geoLookupTasksByIP: [String: Task] = [:]
@@ -36,6 +47,7 @@ final class PlexSessionStore {
private var waveformUnavailableStreamIDs: Set = []
private var waveformLevelTasksByStreamID: [Int: Task] = [:]
private var activeServerIdentifier: String?
+ private var activeAccountScope: String?
private var activeMonitorURL: URL?
var sessions: [PlexSession] {
@@ -45,6 +57,12 @@ final class PlexSessionStore {
var isLoading = false
var errorMessage: String?
var lastUpdated: Date?
+ private(set) var lastHydratedAt: Date?
+ private(set) var activityErrorMessage: String?
+
+ var activitySummary: PlexActivitySummary {
+ PlexActivitySummary(sessions: sessions)
+ }
init(
connectionStore: PlexConnectionStore,
@@ -53,13 +71,15 @@ final class PlexSessionStore {
eventsClient: PlexSessionEventsClient = PlexSessionEventsClient(),
connectionRecheckSleep: @escaping ConnectionRecheckSleep = { duration in
try await Task.sleep(for: duration)
- }
+ },
+ activityClock: PlexActivityRefreshClock = .continuous
) {
self.connectionStore = connectionStore
self.client = client
self.geoIPClient = geoIPClient
self.eventsClient = eventsClient
self.connectionRecheckSleep = connectionRecheckSleep
+ self.activityClock = activityClock
}
var activeStreamCount: Int {
@@ -74,12 +94,46 @@ final class PlexSessionStore {
return terminatingSessionKeys.contains(sessionKey)
}
- func refreshNow() {
+ @discardableResult
+ func refreshNow() -> Task {
Task {
await performFullHydrate()
}
}
+ func setActivityVisible(_ isVisible: Bool, consumer: UUID) {
+ let wasVisible = !activityConsumers.isEmpty
+ if isVisible {
+ activityConsumers.insert(consumer)
+ } else {
+ activityConsumers.remove(consumer)
+ }
+ if activityConsumers.isEmpty {
+ cancelActivityRefresh()
+ } else if !wasVisible {
+ let deadline = lastHydratedInstant.map { $0 + Self.activityRefreshInterval } ?? activityClock.now()
+ scheduleActivityRefresh(at: max(deadline, activityClock.now()))
+ }
+ }
+
+ func systemWillSleep() {
+ isSystemAsleep = true
+ cancelActivityRefresh()
+ cancelHydration()
+ monitorTask?.cancel()
+ monitorTask = nil
+ connectionRecheckTask?.cancel()
+ connectionRecheckTask = nil
+ }
+
+ func systemDidWake() {
+ isSystemAsleep = false
+ guard connectionStore.settings.hasValidConfiguration else { return }
+ startMonitorTask()
+ restartConnectionRecheckTask()
+ scheduleActivityRefresh(at: activityClock.now())
+ }
+
func terminate(_ session: PlexSession, reason: String? = nil) async {
guard let sessionKey = session.canonicalSessionKey else {
errorMessage = PlexSessionStoreError.missingSessionKey.errorDescription
@@ -109,7 +163,7 @@ final class PlexSessionStore {
do {
try await connectionStore.perform { configuration in
- try await hydrateAll(using: configuration, showLoading: false)
+ try await hydrateAll(using: configuration, showLoading: false, afterPlaybackChange: true)
}
} catch {
errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
@@ -182,6 +236,11 @@ final class PlexSessionStore {
clearGeoLookups()
clearWaveformCache()
+ if activeAccountScope != connectionStore.accountCacheScope {
+ clearSessions(resetTimestamp: true)
+ errorMessage = nil
+ }
+
guard connectionStore.settings.hasValidConfiguration else {
activeServerIdentifier = nil
activeMonitorURL = nil
@@ -192,8 +251,11 @@ final class PlexSessionStore {
}
activeServerIdentifier = connectionStore.settings.selectedServerIdentifier
+ activeAccountScope = connectionStore.accountCacheScope
+ guard !isSystemAsleep else { return }
startMonitorTask()
startConnectionRecheckTask()
+ scheduleActivityRefresh(at: activityClock.now())
}
func restartConnectionRecheckTask() {
@@ -221,6 +283,7 @@ final class PlexSessionStore {
do {
let configuration = try await connectionStore.currentConfiguration(forceRefresh: forceRefresh)
+ try Task.checkCancellation()
activeMonitorURL = configuration.serverURL
try await eventsClient.monitor(using: configuration) { [weak self] event in
@@ -234,11 +297,12 @@ final class PlexSessionStore {
reconnectAttempt = 0
forceRefresh = true
} catch is CancellationError {
- activeMonitorURL = nil
return
} catch {
+ guard !Task.isCancelled else { return }
activeMonitorURL = nil
errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
+ activityErrorMessage = errorMessage
reconnectAttempt += 1
forceRefresh = true
@@ -292,6 +356,10 @@ final class PlexSessionStore {
_ event: PlexSessionEvent,
using configuration: PlexConnectionConfiguration
) async throws {
+ try Task.checkCancellation()
+ guard configuration.accountCacheScope == connectionStore.accountCacheScope else {
+ throw CancellationError()
+ }
switch event {
case .connected:
try await hydrateAll(using: configuration, showLoading: true)
@@ -310,18 +378,22 @@ final class PlexSessionStore {
return
}
+ if hydration != nil {
+ hydrationNotifications.append(notification)
+ }
+
if notification.state?.lowercased() == "stopped" {
removeSession(for: sessionKey)
return
}
guard let existingSession = sessionsByKey[sessionKey] else {
- try await rehydrateSession(using: configuration, sessionKey: sessionKey)
+ try await hydrateAll(using: configuration, showLoading: false, afterPlaybackChange: true)
return
}
if notification.requiresHydrate(comparedTo: existingSession) {
- try await rehydrateSession(using: configuration, sessionKey: sessionKey)
+ try await hydrateAll(using: configuration, showLoading: false, afterPlaybackChange: true)
return
}
@@ -331,22 +403,8 @@ final class PlexSessionStore {
lastUpdated = Date()
}
- private func rehydrateSession(
- using configuration: PlexConnectionConfiguration,
- sessionKey: String
- ) async throws {
- if let session = try await client.fetchSession(using: configuration, sessionKey: sessionKey),
- let canonicalSessionKey = session.canonicalSessionKey {
- upsertSession(session, sessionKey: canonicalSessionKey)
- } else {
- removeSession(for: sessionKey)
- }
-
- errorMessage = nil
- lastUpdated = Date()
- }
-
- private func performFullHydrate() async {
+ private func performFullHydrate(showLoading: Bool = true) async {
+ guard !Task.isCancelled, !isSystemAsleep else { return }
guard connectionStore.settings.hasValidConfiguration else {
clearSessions(resetTimestamp: true)
errorMessage = nil
@@ -358,34 +416,87 @@ final class PlexSessionStore {
didChangeConfiguration()
}
- isLoading = true
-
+ let scope = connectionStore.accountCacheScope
do {
try await connectionStore.perform { configuration in
- try await hydrateAll(using: configuration, showLoading: false)
+ try Task.checkCancellation()
+ try await hydrateAll(using: configuration, showLoading: showLoading)
}
+ } catch is CancellationError {
+ return
+ } catch let error as URLError where error.code == .cancelled {
+ return
} catch {
+ guard !Task.isCancelled, scope == connectionStore.accountCacheScope else { return }
errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
+ activityErrorMessage = errorMessage
}
-
- isLoading = false
}
private func hydrateAll(
using configuration: PlexConnectionConfiguration,
- showLoading: Bool
+ showLoading: Bool,
+ afterPlaybackChange: Bool = false
) async throws {
- if showLoading {
- isLoading = true
+ try Task.checkCancellation()
+ guard !isSystemAsleep, configuration.accountCacheScope == connectionStore.accountCacheScope else {
+ throw CancellationError()
+ }
+ if let current = hydration,
+ current.scope != configuration.accountCacheScope || current.url != configuration.serverURL {
+ cancelHydration()
+ }
+ if let current = hydration {
+ // An event received after a request began requires a snapshot taken
+ // after that event. All waiters share the same reconciliation pass.
+ if afterPlaybackChange { hydrationNeedsFollowup = true }
+ if showLoading { isLoading = true }
+ try await current.task.value
+ return
}
- defer {
- if showLoading {
- isLoading = false
+
+ let id = UUID()
+ let task = Task { [weak self] in
+ guard let self else { throw CancellationError() }
+ defer {
+ if self.hydration?.id == id {
+ self.hydration = nil
+ self.hydrationNotifications = []
+ self.hydrationNeedsFollowup = false
+ self.isLoading = false
+ }
+ }
+ do {
+ while true {
+ let fetched = try await self.client.fetchSessions(using: configuration)
+ try Task.checkCancellation()
+ guard self.hydration?.id == id,
+ configuration.accountCacheScope == self.connectionStore.accountCacheScope else {
+ throw CancellationError()
+ }
+ if self.hydrationNeedsFollowup {
+ self.hydrationNeedsFollowup = false
+ self.hydrationNotifications = []
+ continue
+ }
+ self.applyHydratedSessions(fetched)
+ return
+ }
+ } catch is CancellationError {
+ throw CancellationError()
+ } catch let error as URLError where error.code == .cancelled {
+ throw CancellationError()
+ } catch {
+ if self.hydration?.id == id,
+ configuration.accountCacheScope == self.connectionStore.accountCacheScope {
+ self.activityErrorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
+ }
+ throw error
}
}
-
- let fetchedSessions = try await client.fetchSessions(using: configuration)
- applyHydratedSessions(fetchedSessions)
+ hydration = (id, configuration.accountCacheScope, configuration.serverURL, task)
+ isLoading = showLoading
+ try await task.value
}
private func applyHydratedSessions(_ fetchedSessions: [PlexSession]) {
@@ -405,23 +516,28 @@ final class PlexSessionStore {
nextSessionOrder.append(storageKey)
}
+ // Preserve events newer than the HTTP request, including stops. A
+ // completed fetch must not restore an old position or resurrect a session.
+ for notification in hydrationNotifications {
+ guard let key = notification.sessionKey?.nilIfBlank else { continue }
+ if notification.state?.lowercased() == "stopped" {
+ nextSessionsByKey.removeValue(forKey: key)
+ nextSessionOrder.removeAll { $0 == key }
+ } else if let session = nextSessionsByKey[key],
+ !notification.requiresHydrate(comparedTo: session) {
+ nextSessionsByKey[key] = session.applying(playNotification: notification)
+ }
+ }
sessionsByKey = nextSessionsByKey
sessionOrder = nextSessionOrder
pruneWaveformCache()
refreshResolvedLocationsIfNeeded()
errorMessage = nil
lastUpdated = Date()
- }
-
- private func upsertSession(_ session: PlexSession, sessionKey: String) {
- sessionsByKey[sessionKey] = session
-
- if !sessionOrder.contains(sessionKey) {
- sessionOrder.append(sessionKey)
- }
-
- pruneWaveformCache()
- refreshResolvedLocationsIfNeeded()
+ lastHydratedAt = lastUpdated
+ lastHydratedInstant = activityClock.now()
+ activityErrorMessage = nil
+ scheduleActivityRefresh(at: activityClock.now() + Self.activityRefreshInterval)
}
private func removeSession(for sessionKey: String) {
@@ -443,6 +559,9 @@ final class PlexSessionStore {
if resetTimestamp {
lastUpdated = nil
+ lastHydratedAt = nil
+ lastHydratedInstant = nil
+ activityErrorMessage = nil
}
}
@@ -452,6 +571,8 @@ final class PlexSessionStore {
}
private func cancelBackgroundTasks() {
+ cancelActivityRefresh()
+ cancelHydration()
monitorTask?.cancel()
monitorTask = nil
connectionRecheckTask?.cancel()
@@ -462,8 +583,44 @@ final class PlexSessionStore {
waveformLevelTasksByStreamID.removeAll()
}
+ private func cancelHydration() {
+ hydration?.task.cancel()
+ hydration = nil
+ hydrationNotifications = []
+ hydrationNeedsFollowup = false
+ isLoading = false
+ }
+
+ private func cancelActivityRefresh() {
+ activityRefreshID = UUID()
+ activityRefreshTask?.cancel()
+ activityRefreshTask = nil
+ }
+
+ private func scheduleActivityRefresh(at deadline: ContinuousClock.Instant) {
+ cancelActivityRefresh()
+ guard !activityConsumers.isEmpty, !isSystemAsleep,
+ connectionStore.settings.hasValidConfiguration else { return }
+ let id = activityRefreshID
+ let clock = activityClock
+ activityRefreshTask = Task { [weak self] in
+ do {
+ try await clock.sleepUntil(deadline)
+ try Task.checkCancellation()
+ } catch { return }
+ guard let self, self.activityRefreshID == id else { return }
+ await self.performFullHydrate(showLoading: false)
+ // Success schedules from the new snapshot. Failure waits for the
+ // next regular interval; it never stamps old data as fresh.
+ if self.activityRefreshID == id {
+ self.scheduleActivityRefresh(at: clock.now() + Self.activityRefreshInterval)
+ }
+ }
+ }
+
private func startMonitorTask() {
monitorTask?.cancel()
+ activeMonitorURL = nil
monitorTask = Task { [weak self] in
await self?.runMonitorLoop()
}
diff --git a/PlexBar/Stores/PlexSettingsStore.swift b/PlexBar/Stores/PlexSettingsStore.swift
new file mode 100644
index 0000000..3949529
--- /dev/null
+++ b/PlexBar/Stores/PlexSettingsStore.swift
@@ -0,0 +1,686 @@
+import PlexModels
+import Foundation
+import Observation
+
+@MainActor
+@Observable
+final class PlexSettingsStore {
+ private enum DefaultsKeys {
+ static let installIdentifier = "plex.installIdentifier"
+ static let cachedConnectionURL = "plex.serverURL"
+ static let cachedConnectionKind = "plex.cachedConnectionKind"
+ static let clientIdentifier = "plex.clientIdentifier"
+ static let selectedServerIdentifier = "plex.selectedServerIdentifier"
+ static let selectedServerName = "plex.selectedServerName"
+ static let connectionRecheckIntervalSeconds = "plex.connectionRecheckIntervalSeconds"
+ static let historyPollIntervalSeconds = "plex.historyPollIntervalSeconds"
+ static let localVideoQuality = "plex.localVideoQuality"
+ static let remoteVideoQuality = "plex.remoteVideoQuality"
+ static let downloadVideoQuality = "plex.downloadVideoQuality"
+ static let downloadMusicQuality = "plex.downloadMusicQuality"
+ static let downloadSubtitlePreference = "plex.downloadSubtitlePreference"
+ static let qualitySuggestionsEnabled = "plex.qualitySuggestionsEnabled"
+ static let allowsDirectPlay = "plex.allowsDirectPlay"
+ static let allowsDirectStream = "plex.allowsDirectStream"
+ static let forceDirectPlay = "plex.forceDirectPlay"
+ static let videoDynamicRange = "plex.videoDynamicRange"
+ static let videoScalingMode = "plex.videoScalingMode"
+ static let episodeSpoilerPolicy = "plex.episodeSpoilerPolicy"
+ static let autoplayUpNext = "plex.autoplayUpNext"
+ static let autoplayCountdown = "plex.autoplayCountdown"
+ static let passoutProtection = "plex.passoutProtection"
+ static let cinemaPreplayPreference = "plex.cinemaPreplayPreference"
+ static let rewindOnResumeSeconds = "plex.rewindOnResumeSeconds"
+ static let skipIntroBehavior = "plex.skipIntroBehavior"
+ static let skipAdsBehavior = "plex.skipAdsBehavior"
+ static let skipCreditsBehavior = "plex.skipCreditsBehavior"
+ static let registeredJWTKeyID = "plex.registeredJWTKeyID"
+ }
+
+ private let defaults: UserDefaults
+ private let credentialStore: any PlexCredentialPersisting
+ private let loginItemService: any PlexLoginItemControlling
+ private var credentialLoadingTask: Task?
+ private var credentialPersistenceTask: Task?
+ private var credentialPersistenceErrors: [String: Error] = [:]
+ private var isApplyingLoadedCredentials = false
+
+ var cachedConnectionURLString: String {
+ didSet {
+ defaults.set(cachedConnectionURLString, forKey: DefaultsKeys.cachedConnectionURL)
+ }
+ }
+
+ var cachedConnectionKind: PlexConnectionKind? {
+ didSet {
+ defaults.set(cachedConnectionKind?.rawValue, forKey: DefaultsKeys.cachedConnectionKind)
+ }
+ }
+
+ var selectedServerIdentifier: String? {
+ didSet {
+ defaults.set(selectedServerIdentifier, forKey: DefaultsKeys.selectedServerIdentifier)
+ }
+ }
+
+ var selectedServerName: String? {
+ didSet {
+ defaults.set(selectedServerName, forKey: DefaultsKeys.selectedServerName)
+ }
+ }
+
+ var userToken: String {
+ didSet {
+ persistUserToken()
+ }
+ }
+
+ var serverToken: String {
+ didSet {
+ persistServerToken()
+ }
+ }
+
+ var connectionRecheckIntervalSeconds: Int {
+ didSet {
+ let normalizedValue = Self.normalizedConnectionRecheckIntervalSeconds(connectionRecheckIntervalSeconds)
+ if connectionRecheckIntervalSeconds != normalizedValue {
+ connectionRecheckIntervalSeconds = normalizedValue
+ return
+ }
+
+ defaults.set(normalizedValue, forKey: DefaultsKeys.connectionRecheckIntervalSeconds)
+ }
+ }
+
+ var historyPollIntervalSeconds: Int {
+ didSet {
+ let normalizedValue = Self.normalizedHistoryPollIntervalSeconds(historyPollIntervalSeconds)
+ if historyPollIntervalSeconds != normalizedValue {
+ historyPollIntervalSeconds = normalizedValue
+ return
+ }
+
+ defaults.set(normalizedValue, forKey: DefaultsKeys.historyPollIntervalSeconds)
+ }
+ }
+
+ var localVideoQuality: PlexVideoQuality {
+ didSet {
+ defaults.set(localVideoQuality.rawValue, forKey: DefaultsKeys.localVideoQuality)
+ }
+ }
+
+ var remoteVideoQuality: PlexVideoQuality {
+ didSet {
+ defaults.set(remoteVideoQuality.rawValue, forKey: DefaultsKeys.remoteVideoQuality)
+ }
+ }
+
+ var downloadVideoQuality: PlexDownloadVideoQuality {
+ didSet {
+ defaults.set(downloadVideoQuality.rawValue, forKey: DefaultsKeys.downloadVideoQuality)
+ }
+ }
+
+ var downloadMusicQuality: PlexMusicQuality {
+ didSet {
+ defaults.set(downloadMusicQuality.rawValue, forKey: DefaultsKeys.downloadMusicQuality)
+ }
+ }
+
+ var downloadSubtitlePreference: PlexDownloadSubtitlePreference {
+ didSet {
+ defaults.set(
+ downloadSubtitlePreference.rawValue,
+ forKey: DefaultsKeys.downloadSubtitlePreference
+ )
+ }
+ }
+
+ var qualitySuggestionsEnabled: Bool {
+ didSet {
+ defaults.set(qualitySuggestionsEnabled, forKey: DefaultsKeys.qualitySuggestionsEnabled)
+ }
+ }
+
+ var allowsDirectPlay: Bool {
+ didSet {
+ defaults.set(allowsDirectPlay, forKey: DefaultsKeys.allowsDirectPlay)
+ }
+ }
+
+ var allowsDirectStream: Bool {
+ didSet {
+ defaults.set(allowsDirectStream, forKey: DefaultsKeys.allowsDirectStream)
+ }
+ }
+
+ var forceDirectPlay: Bool {
+ didSet {
+ defaults.set(forceDirectPlay, forKey: DefaultsKeys.forceDirectPlay)
+ }
+ }
+
+ var videoDynamicRange: PlexVideoDisplayDynamicRange {
+ didSet {
+ defaults.set(videoDynamicRange.rawValue, forKey: DefaultsKeys.videoDynamicRange)
+ }
+ }
+
+ var videoScalingMode: PlexVideoScalingMode {
+ didSet {
+ defaults.set(videoScalingMode.rawValue, forKey: DefaultsKeys.videoScalingMode)
+ }
+ }
+
+ var episodeSpoilerPolicy: PlexEpisodeSpoilerPolicy {
+ didSet {
+ defaults.set(episodeSpoilerPolicy.rawValue, forKey: DefaultsKeys.episodeSpoilerPolicy)
+ }
+ }
+
+ var autoplayUpNext: Bool {
+ didSet {
+ defaults.set(autoplayUpNext, forKey: DefaultsKeys.autoplayUpNext)
+ }
+ }
+
+ var autoplayCountdown: PlexAutoplayCountdown {
+ didSet {
+ defaults.set(autoplayCountdown.rawValue, forKey: DefaultsKeys.autoplayCountdown)
+ }
+ }
+
+ var passoutProtection: PlexPassoutProtection {
+ didSet {
+ defaults.set(passoutProtection.rawValue, forKey: DefaultsKeys.passoutProtection)
+ }
+ }
+
+ var cinemaPreplayPreference: PlexCinemaPreplayPreference {
+ didSet {
+ defaults.set(
+ cinemaPreplayPreference.rawValue,
+ forKey: DefaultsKeys.cinemaPreplayPreference
+ )
+ }
+ }
+
+ var rewindOnResume: PlexRewindOnResume {
+ didSet {
+ defaults.set(rewindOnResume.seconds, forKey: DefaultsKeys.rewindOnResumeSeconds)
+ }
+ }
+
+ var skipIntroBehavior: PlexPlaybackMarkerBehavior {
+ didSet {
+ defaults.set(skipIntroBehavior.rawValue, forKey: DefaultsKeys.skipIntroBehavior)
+ }
+ }
+
+ var skipAdsBehavior: PlexPlaybackMarkerBehavior {
+ didSet {
+ defaults.set(skipAdsBehavior.rawValue, forKey: DefaultsKeys.skipAdsBehavior)
+ }
+ }
+
+ var skipCreditsBehavior: PlexPlaybackMarkerBehavior {
+ didSet {
+ defaults.set(skipCreditsBehavior.rawValue, forKey: DefaultsKeys.skipCreditsBehavior)
+ }
+ }
+
+ private(set) var clientIdentifier: String {
+ didSet {
+ defaults.set(clientIdentifier, forKey: DefaultsKeys.clientIdentifier)
+ }
+ }
+
+ private(set) var registeredJWTKeyID: String? {
+ didSet {
+ defaults.set(registeredJWTKeyID, forKey: DefaultsKeys.registeredJWTKeyID)
+ }
+ }
+
+ private(set) var openAtLoginStatus: PlexLoginItemStatus
+ private(set) var hasLoadedCredentials: Bool
+ private(set) var isLoadingCredentials = false
+ private(set) var credentialLoadingErrorMessage: String?
+ private(set) var credentialPersistenceErrorMessage: String?
+ var openAtLoginErrorMessage: String?
+
+ init(
+ defaults: UserDefaults = .standard,
+ keychain: KeychainStore = KeychainStore(service: AppConstants.bundleIdentifier),
+ loginItemService: any PlexLoginItemControlling = PlexLoginItemService(),
+ credentialStore: (any PlexCredentialPersisting)? = nil,
+ initialCredentials: PlexStoredCredentials? = nil
+ ) {
+ self.defaults = defaults
+ if let credentialStore {
+ self.credentialStore = credentialStore
+ } else if let initialCredentials {
+ self.credentialStore = PlexMemoryCredentialStore(credentials: initialCredentials)
+ } else {
+ self.credentialStore = PlexKeychainCredentialStore(keychain: keychain)
+ }
+ self.loginItemService = loginItemService
+ cachedConnectionURLString = defaults.string(forKey: DefaultsKeys.cachedConnectionURL) ?? ""
+ cachedConnectionKind = defaults.string(forKey: DefaultsKeys.cachedConnectionKind)
+ .flatMap(PlexConnectionKind.init(rawValue:))
+
+ let installIdentifier = Self.loadInstallIdentifier(from: defaults)
+ clientIdentifier = Self.loadClientIdentifier(from: defaults, installIdentifier: installIdentifier)
+ registeredJWTKeyID = defaults.string(forKey: DefaultsKeys.registeredJWTKeyID)?.nilIfBlank
+
+ selectedServerIdentifier = defaults.string(forKey: DefaultsKeys.selectedServerIdentifier)
+ selectedServerName = defaults.string(forKey: DefaultsKeys.selectedServerName)
+ userToken = initialCredentials?.userToken ?? ""
+ serverToken = initialCredentials?.serverToken ?? ""
+ hasLoadedCredentials = initialCredentials != nil
+ connectionRecheckIntervalSeconds = Self.normalizedConnectionRecheckIntervalSeconds(
+ defaults.object(forKey: DefaultsKeys.connectionRecheckIntervalSeconds) as? Int
+ ?? AppConstants.defaultConnectionRecheckIntervalSeconds
+ )
+ historyPollIntervalSeconds = Self.normalizedHistoryPollIntervalSeconds(
+ defaults.object(forKey: DefaultsKeys.historyPollIntervalSeconds) as? Int
+ ?? AppConstants.defaultHistoryPollIntervalSeconds
+ )
+ localVideoQuality = defaults.string(forKey: DefaultsKeys.localVideoQuality)
+ .flatMap(PlexVideoQuality.init(rawValue:)) ?? .original
+ remoteVideoQuality = defaults.string(forKey: DefaultsKeys.remoteVideoQuality)
+ .flatMap(PlexVideoQuality.init(rawValue:)) ?? .original
+ downloadVideoQuality = defaults.string(forKey: DefaultsKeys.downloadVideoQuality)
+ .flatMap(PlexDownloadVideoQuality.init(rawValue:)) ?? .original
+ downloadMusicQuality = defaults.string(forKey: DefaultsKeys.downloadMusicQuality)
+ .flatMap(PlexMusicQuality.init(rawValue:)) ?? .original
+ downloadSubtitlePreference = defaults.string(
+ forKey: DefaultsKeys.downloadSubtitlePreference
+ ).flatMap(PlexDownloadSubtitlePreference.init(rawValue:)) ?? .selectable
+ qualitySuggestionsEnabled =
+ defaults.object(forKey: DefaultsKeys.qualitySuggestionsEnabled) as? Bool ?? true
+ allowsDirectPlay = defaults.object(forKey: DefaultsKeys.allowsDirectPlay) as? Bool ?? true
+ allowsDirectStream = defaults.object(forKey: DefaultsKeys.allowsDirectStream) as? Bool ?? true
+ forceDirectPlay = defaults.object(forKey: DefaultsKeys.forceDirectPlay) as? Bool ?? false
+ videoDynamicRange = defaults.string(forKey: DefaultsKeys.videoDynamicRange)
+ .flatMap(PlexVideoDisplayDynamicRange.init(rawValue:)) ?? .automatic
+ videoScalingMode = defaults.string(forKey: DefaultsKeys.videoScalingMode)
+ .flatMap(PlexVideoScalingMode.init(rawValue:)) ?? .fit
+ episodeSpoilerPolicy = defaults.string(forKey: DefaultsKeys.episodeSpoilerPolicy)
+ .flatMap(PlexEpisodeSpoilerPolicy.init(rawValue:)) ?? .off
+ autoplayUpNext = defaults.object(forKey: DefaultsKeys.autoplayUpNext) as? Bool ?? true
+ autoplayCountdown = (defaults.object(forKey: DefaultsKeys.autoplayCountdown) as? Int)
+ .flatMap(PlexAutoplayCountdown.init(rawValue:)) ?? .tenSeconds
+ passoutProtection = (defaults.object(forKey: DefaultsKeys.passoutProtection) as? Int)
+ .flatMap(PlexPassoutProtection.init(rawValue:)) ?? .twoHours
+ cinemaPreplayPreference = (
+ defaults.object(forKey: DefaultsKeys.cinemaPreplayPreference) as? Int
+ ).flatMap(PlexCinemaPreplayPreference.init(rawValue:)) ?? .off
+ rewindOnResume = PlexRewindOnResume(
+ seconds: defaults.object(forKey: DefaultsKeys.rewindOnResumeSeconds) as? Int ?? 0
+ )
+ skipIntroBehavior = defaults.string(forKey: DefaultsKeys.skipIntroBehavior)
+ .flatMap(PlexPlaybackMarkerBehavior.init(rawValue:)) ?? .manually
+ skipAdsBehavior = defaults.string(forKey: DefaultsKeys.skipAdsBehavior)
+ .flatMap(PlexPlaybackMarkerBehavior.init(rawValue:)) ?? .manually
+ skipCreditsBehavior = defaults.string(forKey: DefaultsKeys.skipCreditsBehavior)
+ .flatMap(PlexPlaybackMarkerBehavior.init(rawValue:)) ?? .manually
+ openAtLoginStatus = loginItemService.status()
+ credentialLoadingErrorMessage = nil
+ credentialPersistenceErrorMessage = nil
+ openAtLoginErrorMessage = nil
+ }
+
+ var normalizedServerURL: URL? {
+ PlexURLBuilder.normalizeServerURL(cachedConnectionURLString)
+ }
+
+ var trimmedUserToken: String {
+ userToken.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ var trimmedServerToken: String {
+ serverToken.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ var hasValidConfiguration: Bool {
+ selectedServerIdentifier?.nilIfBlank != nil && !trimmedServerToken.isEmpty
+ }
+
+ var hasAuthenticatedAccount: Bool {
+ !trimmedUserToken.isEmpty
+ }
+
+ var autoplayPreferences: PlexAutoplayPreferences {
+ PlexAutoplayPreferences(
+ isEnabled: autoplayUpNext,
+ countdown: autoplayCountdown,
+ passoutProtection: passoutProtection
+ )
+ }
+
+ var playbackStreamingPolicy: PlexPlaybackStreamingPolicy {
+ PlexPlaybackStreamingPolicy(
+ allowsDirectPlay: allowsDirectPlay,
+ allowsDirectStream: allowsDirectStream,
+ forceDirectPlay: forceDirectPlay
+ )
+ }
+
+ var downloadPreferences: PlexDownloadPreferences {
+ PlexDownloadPreferences(
+ videoQuality: downloadVideoQuality,
+ musicQuality: downloadMusicQuality,
+ subtitlePreference: downloadSubtitlePreference
+ )
+ }
+
+ var playbackMarkerPreferences: PlexPlaybackMarkerPreferences {
+ PlexPlaybackMarkerPreferences(
+ intro: skipIntroBehavior,
+ ads: skipAdsBehavior,
+ credits: skipCreditsBehavior
+ )
+ }
+
+ var opensAtLogin: Bool {
+ switch openAtLoginStatus {
+ case .enabled, .requiresApproval:
+ return true
+ case .notRegistered, .notFound:
+ return false
+ }
+ }
+
+ var openAtLoginRequiresApproval: Bool {
+ openAtLoginStatus == .requiresApproval
+ }
+
+ func saveAuthenticatedUserToken(_ token: String) async throws {
+ let normalizedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
+ let previousToken = trimmedUserToken
+ try Task.checkCancellation()
+ let persistenceTask = enqueueCredentialPersistence(
+ normalizedToken.nilIfBlank,
+ account: KeychainAccounts.userToken
+ )
+ try await persistenceTask.value
+
+ do {
+ try Task.checkCancellation()
+ } catch {
+ let rollbackTask = enqueueCredentialPersistence(
+ previousToken.nilIfBlank,
+ account: KeychainAccounts.userToken
+ )
+ try? await rollbackTask.value
+ throw error
+ }
+
+ guard userToken != normalizedToken else { return }
+ isApplyingLoadedCredentials = true
+ userToken = normalizedToken
+ isApplyingLoadedCredentials = false
+ }
+
+ func saveServerSelection(_ server: PlexServerResource) {
+ selectedServerIdentifier = server.id
+ selectedServerName = server.name
+ if serverToken != server.accessToken {
+ serverToken = server.accessToken
+ }
+ clearCachedConnection()
+ }
+
+ func saveResolvedConnection(_ connection: PlexResolvedConnection) {
+ cachedConnectionURLString = connection.url.absoluteString
+ cachedConnectionKind = connection.kind
+ }
+
+ func clearCachedConnection() {
+ cachedConnectionURLString = ""
+ cachedConnectionKind = nil
+ }
+
+ func clearAuthentication() {
+ selectedServerIdentifier = nil
+ selectedServerName = nil
+ clearCachedConnection()
+ userToken = ""
+ serverToken = ""
+ }
+
+ func markJWTKeyRegistered(keyID: String) {
+ guard let keyID = keyID.nilIfBlank,
+ registeredJWTKeyID != keyID else {
+ return
+ }
+ registeredJWTKeyID = keyID
+ }
+
+ func refreshOpenAtLoginStatus() {
+ openAtLoginStatus = loginItemService.status()
+ openAtLoginErrorMessage = nil
+ }
+
+ func setOpenAtLogin(_ enabled: Bool) {
+ openAtLoginErrorMessage = nil
+
+ do {
+ try loginItemService.setEnabled(enabled)
+ refreshOpenAtLoginStatus()
+
+ if enabled && openAtLoginStatus == .notFound {
+ openAtLoginErrorMessage = "PlexBar could not register itself as a login item."
+ }
+ } catch {
+ refreshOpenAtLoginStatus()
+ openAtLoginErrorMessage = openAtLoginActionErrorMessage(for: enabled, error: error)
+ }
+ }
+
+ func openLoginItemsSystemSettings() {
+ loginItemService.openSystemSettingsLoginItems()
+ }
+
+ private func openAtLoginActionErrorMessage(for enabled: Bool, error: Error) -> String {
+ let description = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
+
+ if enabled {
+ return "PlexBar could not enable Open at Login. \(description)"
+ }
+
+ return "PlexBar could not disable Open at Login. \(description)"
+ }
+
+ private static func normalizedConnectionRecheckIntervalSeconds(_ value: Int) -> Int {
+ guard AppConstants.allowedConnectionRecheckIntervalSeconds.contains(value) else {
+ return AppConstants.defaultConnectionRecheckIntervalSeconds
+ }
+
+ return value
+ }
+
+ private static func normalizedHistoryPollIntervalSeconds(_ value: Int) -> Int {
+ guard AppConstants.allowedHistoryPollIntervalSeconds.contains(value) else {
+ return AppConstants.defaultHistoryPollIntervalSeconds
+ }
+
+ return value
+ }
+
+ private static func loadInstallIdentifier(from defaults: UserDefaults) -> String {
+ if let existingInstallIdentifier = defaults.string(forKey: DefaultsKeys.installIdentifier)?.nilIfBlank {
+ return existingInstallIdentifier
+ }
+
+ let installIdentifier = defaults.string(forKey: DefaultsKeys.clientIdentifier)?.nilIfBlank ?? newIdentifier()
+ defaults.set(installIdentifier, forKey: DefaultsKeys.installIdentifier)
+ return installIdentifier
+ }
+
+ private static func loadClientIdentifier(from defaults: UserDefaults, installIdentifier: String) -> String {
+ if let existingClientIdentifier = defaults.string(forKey: DefaultsKeys.clientIdentifier)?.nilIfBlank {
+ return existingClientIdentifier
+ }
+
+ defaults.set(installIdentifier, forKey: DefaultsKeys.clientIdentifier)
+ return installIdentifier
+ }
+
+ private static func newIdentifier() -> String {
+ UUID().uuidString
+ }
+}
+
+extension PlexSettingsStore: PlexAccountJWTStorage {
+ var storedAccountToken: String {
+ trimmedUserToken
+ }
+
+ func persistAccountToken(_ token: String) async throws {
+ try await saveAuthenticatedUserToken(token)
+ }
+}
+
+private extension PlexSettingsStore {
+ func persistUserToken() {
+ guard !isApplyingLoadedCredentials else {
+ return
+ }
+ enqueueCredentialPersistence(trimmedUserToken.nilIfBlank, account: KeychainAccounts.userToken)
+ }
+
+ func persistServerToken() {
+ guard !isApplyingLoadedCredentials else {
+ return
+ }
+ enqueueCredentialPersistence(trimmedServerToken.nilIfBlank, account: KeychainAccounts.serverToken)
+ }
+
+ @discardableResult
+ func enqueueCredentialPersistence(
+ _ value: String?,
+ account: String
+ ) -> Task {
+ let previousTask = credentialPersistenceTask
+ let credentialStore = credentialStore
+ let task = Task { @MainActor [weak self] in
+ _ = try? await previousTask?.value
+ do {
+ try await credentialStore.replace(value, account: account)
+ self?.recordCredentialPersistenceSuccess(account: account)
+ } catch {
+ self?.recordCredentialPersistenceFailure(error, account: account)
+ throw error
+ }
+ }
+ credentialPersistenceTask = task
+ return task
+ }
+}
+
+extension PlexSettingsStore {
+ func loadCredentials() async {
+ guard !hasLoadedCredentials else {
+ return
+ }
+
+ if let credentialLoadingTask {
+ await finishCredentialLoad(credentialLoadingTask)
+ return
+ }
+
+ let credentialStore = credentialStore
+ let loadingTask = Task {
+ try await credentialStore.loadCredentials()
+ }
+ credentialLoadingTask = loadingTask
+ isLoadingCredentials = true
+ credentialLoadingErrorMessage = nil
+ await finishCredentialLoad(loadingTask)
+ }
+
+ func waitForCredentialPersistence() async throws {
+ try await credentialPersistenceTask?.value
+ if let error = credentialPersistenceErrors
+ .sorted(by: { $0.key < $1.key })
+ .first?.value {
+ throw error
+ }
+ }
+
+ private func finishCredentialLoad(_ task: Task) async {
+ do {
+ apply(try await task.value)
+ } catch {
+ hasLoadedCredentials = false
+ isLoadingCredentials = false
+ credentialLoadingTask = nil
+ credentialLoadingErrorMessage = Self.credentialErrorMessage(error)
+ }
+ }
+
+ private func apply(_ credentials: PlexStoredCredentials) {
+ guard !hasLoadedCredentials else {
+ return
+ }
+
+ isApplyingLoadedCredentials = true
+ userToken = credentials.userToken
+ serverToken = credentials.serverToken
+ isApplyingLoadedCredentials = false
+ hasLoadedCredentials = true
+ isLoadingCredentials = false
+ credentialLoadingTask = nil
+ credentialLoadingErrorMessage = nil
+ }
+
+ private static func credentialErrorMessage(_ error: Error) -> String {
+ (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
+ }
+
+ private func recordCredentialPersistenceSuccess(account: String) {
+ credentialPersistenceErrors[account] = nil
+ updateCredentialPersistenceErrorMessage()
+ }
+
+ private func recordCredentialPersistenceFailure(_ error: Error, account: String) {
+ credentialPersistenceErrors[account] = error
+ updateCredentialPersistenceErrorMessage()
+ }
+
+ private func updateCredentialPersistenceErrorMessage() {
+ let messages = credentialPersistenceErrors.values
+ .map(Self.credentialErrorMessage)
+ credentialPersistenceErrorMessage = Array(Set(messages)).sorted().joined(separator: " ").nilIfBlank
+ }
+
+ var connectionRecheckIntervalDuration: Duration? {
+ guard connectionRecheckIntervalSeconds > 0 else {
+ return nil
+ }
+
+ return .seconds(connectionRecheckIntervalSeconds)
+ }
+
+ var historyPollIntervalDuration: Duration {
+ .seconds(historyPollIntervalSeconds)
+ }
+
+ func videoQuality(for connectionKind: PlexConnectionKind?) -> PlexVideoQuality {
+ PlexVideoQualityPreferences(
+ local: localVideoQuality,
+ remote: remoteVideoQuality
+ ).quality(for: connectionKind)
+ }
+
+ func setVideoQuality(_ quality: PlexVideoQuality, for connectionKind: PlexConnectionKind?) {
+ if connectionKind == .local {
+ localVideoQuality = quality
+ } else {
+ remoteVideoQuality = quality
+ }
+ }
+}
diff --git a/PlexBar/Support/AppConstants.swift b/PlexBar/Support/AppConstants.swift
new file mode 100644
index 0000000..d761ff3
--- /dev/null
+++ b/PlexBar/Support/AppConstants.swift
@@ -0,0 +1,30 @@
+import Foundation
+
+enum AppConstants {
+ static let appName = "PlexBar"
+ static let bundleIdentifier: String = {
+ guard let bundleIdentifier = Bundle.main.bundleIdentifier else {
+ preconditionFailure("The application bundle identifier is missing")
+ }
+ return bundleIdentifier
+ }()
+ static let productVersion: String = {
+ guard let productVersion = Bundle.main.object(
+ forInfoDictionaryKey: "CFBundleShortVersionString"
+ ) as? String else {
+ preconditionFailure("The application version is missing")
+ }
+ return productVersion
+ }()
+ static let defaultConnectionRecheckIntervalSeconds = 900
+ static let allowedConnectionRecheckIntervalSeconds = [0, 300, 900, 1_800, 3_600]
+ static let defaultHistoryPollIntervalSeconds = 900
+ static let allowedHistoryPollIntervalSeconds = [900, 3_600, 86_400]
+}
+
+enum KeychainAccounts {
+ static let userToken = "plex-user-token"
+ static let serverToken = "plex-server-token"
+ static let jwtKeyID = "plex-jwt-key-id"
+ static let jwtPrivateKey = "plex-jwt-private-key"
+}
diff --git a/PlexBar/Support/KeychainStore.swift b/PlexBar/Support/KeychainStore.swift
new file mode 100644
index 0000000..ff4a858
--- /dev/null
+++ b/PlexBar/Support/KeychainStore.swift
@@ -0,0 +1,204 @@
+import PlexModels
+import Foundation
+import Security
+
+enum PlexKeychainOperation: String, Sendable {
+ case read
+ case update
+ case add
+ case delete
+}
+
+struct PlexKeychainError: Error, Equatable, LocalizedError, Sendable {
+ let operation: PlexKeychainOperation
+ let status: OSStatus
+
+ var errorDescription: String? {
+ let systemDescription = SecCopyErrorMessageString(status, nil) as String?
+ let detail = systemDescription?.nilIfBlank ?? "Security framework status \(status)"
+ return "Keychain \(operation.rawValue) failed. \(detail)"
+ }
+}
+
+protocol PlexKeychainBackend: Sendable {
+ func read(service: String, account: String) throws -> String?
+ func write(_ value: String, service: String, account: String) throws
+ func delete(service: String, account: String) throws
+}
+
+struct PlexSecurityKeychainBackend: PlexKeychainBackend {
+ func read(service: String, account: String) throws -> String? {
+ let query = Self.readQuery(service: service, account: account)
+
+ var item: CFTypeRef?
+ let status = SecItemCopyMatching(query as CFDictionary, &item)
+
+ if status == errSecItemNotFound {
+ return nil
+ }
+ guard status == errSecSuccess else {
+ throw PlexKeychainError(operation: .read, status: status)
+ }
+ guard let data = item as? Data,
+ let string = String(data: data, encoding: .utf8) else {
+ throw PlexKeychainError(operation: .read, status: errSecDecode)
+ }
+
+ return string
+ }
+
+ func write(_ value: String, service: String, account: String) throws {
+ let data = Data(value.utf8)
+ let query = Self.itemIdentityQuery(service: service, account: account)
+ let attributes = [kSecValueData as String: data]
+
+ let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
+
+ if updateStatus == errSecSuccess {
+ return
+ }
+ guard updateStatus == errSecItemNotFound else {
+ throw PlexKeychainError(operation: .update, status: updateStatus)
+ }
+
+ var createQuery = query
+ createQuery[kSecValueData as String] = data
+ let addStatus = SecItemAdd(createQuery as CFDictionary, nil)
+ guard addStatus == errSecSuccess else {
+ throw PlexKeychainError(operation: .add, status: addStatus)
+ }
+ }
+
+ func delete(service: String, account: String) throws {
+ let status = SecItemDelete(
+ Self.itemIdentityQuery(service: service, account: account) as CFDictionary
+ )
+ guard status == errSecSuccess || status == errSecItemNotFound else {
+ throw PlexKeychainError(operation: .delete, status: status)
+ }
+ }
+
+ static func itemIdentityQuery(service: String, account: String) -> [String: Any] {
+ [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account
+ ]
+ }
+
+ static func readQuery(service: String, account: String) -> [String: Any] {
+ var query = itemIdentityQuery(service: service, account: account)
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
+ query[kSecReturnData as String] = true
+ return query
+ }
+}
+
+actor PlexKeychainAccessLane {
+ static let shared = PlexKeychainAccessLane()
+
+ private let backend: any PlexKeychainBackend
+
+ init(backend: any PlexKeychainBackend = PlexSecurityKeychainBackend()) {
+ self.backend = backend
+ }
+
+ func read(service: String, account: String) throws -> String? {
+ try backend.read(service: service, account: account)
+ }
+
+ func write(_ value: String, service: String, account: String) throws {
+ try backend.write(value, service: service, account: account)
+ }
+
+ func delete(service: String, account: String) throws {
+ try backend.delete(service: service, account: account)
+ }
+}
+
+struct KeychainStore: Sendable {
+ let service: String
+ private let accessLane: PlexKeychainAccessLane
+
+ init(
+ service: String,
+ accessLane: PlexKeychainAccessLane = .shared
+ ) {
+ self.service = service
+ self.accessLane = accessLane
+ }
+
+ func read(account: String) async throws -> String? {
+ try await accessLane.read(service: service, account: account)
+ }
+
+ func write(_ value: String, account: String) async throws {
+ try await accessLane.write(value, service: service, account: account)
+ }
+
+ func delete(account: String) async throws {
+ try await accessLane.delete(service: service, account: account)
+ }
+}
+
+struct PlexStoredCredentials: Equatable, Sendable {
+ let userToken: String
+ let serverToken: String
+
+ static let empty = PlexStoredCredentials(userToken: "", serverToken: "")
+}
+
+protocol PlexCredentialPersisting: Actor {
+ func loadCredentials() async throws -> PlexStoredCredentials
+ func replace(_ value: String?, account: String) async throws
+}
+
+actor PlexKeychainCredentialStore: PlexCredentialPersisting {
+ private let keychain: KeychainStore
+
+ init(keychain: KeychainStore) {
+ self.keychain = keychain
+ }
+
+ func loadCredentials() async throws -> PlexStoredCredentials {
+ async let userToken = keychain.read(account: KeychainAccounts.userToken)
+ async let serverToken = keychain.read(account: KeychainAccounts.serverToken)
+ let credentials = try await (userToken, serverToken)
+
+ return PlexStoredCredentials(
+ userToken: credentials.0 ?? "",
+ serverToken: credentials.1 ?? ""
+ )
+ }
+
+ func replace(_ value: String?, account: String) async throws {
+ guard let value = value?.nilIfBlank else {
+ try await keychain.delete(account: account)
+ return
+ }
+
+ try await keychain.write(value, account: account)
+ }
+}
+
+actor PlexMemoryCredentialStore: PlexCredentialPersisting {
+ private var values: [String: String]
+
+ init(credentials: PlexStoredCredentials = .empty) {
+ values = [
+ KeychainAccounts.userToken: credentials.userToken,
+ KeychainAccounts.serverToken: credentials.serverToken
+ ]
+ }
+
+ func loadCredentials() async -> PlexStoredCredentials {
+ PlexStoredCredentials(
+ userToken: values[KeychainAccounts.userToken] ?? "",
+ serverToken: values[KeychainAccounts.serverToken] ?? ""
+ )
+ }
+
+ func replace(_ value: String?, account: String) async {
+ values[account] = value?.nilIfBlank
+ }
+}
diff --git a/PlexBar/Support/MenuBarIcon.swift b/PlexBar/Support/MenuBarIcon.swift
new file mode 100644
index 0000000..62ed067
--- /dev/null
+++ b/PlexBar/Support/MenuBarIcon.swift
@@ -0,0 +1,13 @@
+import AppKit
+
+@MainActor
+enum MenuBarIcon {
+ static let image: NSImage = {
+ guard let image = Bundle.main.image(forResource: "MenuBarIcon") else {
+ preconditionFailure("The menu-bar icon is missing from the application bundle")
+ }
+
+ image.isTemplate = true
+ return image
+ }()
+}
diff --git a/PlexBar/Support/NativePlaybackCapabilityProbe.swift b/PlexBar/Support/NativePlaybackCapabilityProbe.swift
new file mode 100644
index 0000000..c992846
--- /dev/null
+++ b/PlexBar/Support/NativePlaybackCapabilityProbe.swift
@@ -0,0 +1,142 @@
+import AVFoundation
+import CoreMedia
+import VideoToolbox
+
+enum NativePlaybackCapabilityProbe {
+ static func current() -> PlexPlaybackCapabilities {
+ capabilities(
+ hardwareDecodeSupported: VTIsHardwareDecodeSupported,
+ playableExtendedMIMEType: AVURLAsset.isPlayableExtendedMIMEType
+ )
+ }
+
+ static func capabilities(
+ hardwareDecodeSupported: (CMVideoCodecType) -> Bool,
+ playableExtendedMIMEType: (String) -> Bool
+ ) -> PlexPlaybackCapabilities {
+ let videoCodecs: [String] = videoCandidates.compactMap { candidate in
+ guard hardwareDecodeSupported(candidate.codecType),
+ candidate.extendedMIMETypes.allSatisfy(playableExtendedMIMEType) else {
+ return nil
+ }
+ return candidate.plexCodec
+ }
+ let audioCodecs: [String] = audioCandidates.compactMap { candidate in
+ playableExtendedMIMEType(candidate.extendedMIMEType)
+ ? candidate.plexCodec
+ : nil
+ }
+ let musicProfiles: [PlexMusicDirectPlayProfile] = musicCandidates.compactMap { candidate in
+ guard playableExtendedMIMEType(candidate.extendedMIMEType) else {
+ return nil
+ }
+ return PlexMusicDirectPlayProfile(
+ container: candidate.plexContainer,
+ audioCodec: candidate.plexCodec
+ )
+ }
+ let hlsAudioCodecs: [String] = hlsAudioCandidates.compactMap { candidate in
+ playableExtendedMIMEType(candidate.extendedMIMEType)
+ ? candidate.plexCodec
+ : nil
+ }
+
+ return PlexPlaybackCapabilities(
+ directPlayContainers: ["m4v", "mov", "mp4"],
+ directPlayVideoCodecs: Set(videoCodecs),
+ directPlayAudioCodecs: Set(audioCodecs),
+ directPlayMusicProfiles: Set(musicProfiles),
+ hlsStreamingAudioCodecs: Set(hlsAudioCodecs)
+ )
+ }
+
+ private struct VideoCandidate {
+ let plexCodec: String
+ let codecType: CMVideoCodecType
+ let extendedMIMETypes: [String]
+ }
+
+ private struct AudioCandidate {
+ let plexCodec: String
+ let extendedMIMEType: String
+ }
+
+ private struct MusicCandidate {
+ let plexContainer: String
+ let plexCodec: String
+ let extendedMIMEType: String
+ }
+
+ private static let videoCandidates = [
+ VideoCandidate(
+ plexCodec: "h264",
+ codecType: kCMVideoCodecType_H264,
+ extendedMIMETypes: [
+ #"video/mp4; codecs="avc1.640028, mp4a.40.2""#
+ ]
+ ),
+ VideoCandidate(
+ plexCodec: "hevc",
+ codecType: kCMVideoCodecType_HEVC,
+ extendedMIMETypes: [
+ #"video/mp4; codecs="hvc1.1.6.L120.B0, mp4a.40.2""#,
+ #"video/mp4; codecs="hvc1.2.4.L153.B0, mp4a.40.2""#,
+ ]
+ ),
+ VideoCandidate(
+ plexCodec: "av1",
+ codecType: kCMVideoCodecType_AV1,
+ extendedMIMETypes: [
+ #"video/mp4; codecs="av01.0.08M.08, mp4a.40.2""#
+ ]
+ ),
+ ]
+
+ private static let audioCandidates = [
+ AudioCandidate(plexCodec: "aac", extendedMIMEType: #"video/mp4; codecs="mp4a.40.2""#),
+ AudioCandidate(plexCodec: "ac3", extendedMIMEType: #"video/mp4; codecs="ac-3""#),
+ AudioCandidate(plexCodec: "eac3", extendedMIMEType: #"video/mp4; codecs="ec-3""#),
+ AudioCandidate(plexCodec: "alac", extendedMIMEType: #"video/mp4; codecs="alac""#),
+ AudioCandidate(plexCodec: "flac", extendedMIMEType: #"video/mp4; codecs="fLaC""#),
+ AudioCandidate(plexCodec: "opus", extendedMIMEType: #"video/mp4; codecs="Opus""#),
+ ]
+
+ private static let musicCandidates = [
+ MusicCandidate(
+ plexContainer: "aac",
+ plexCodec: "aac",
+ extendedMIMEType: #"audio/aac; codecs="mp4a.40.2""#
+ ),
+ MusicCandidate(
+ plexContainer: "mp3",
+ plexCodec: "mp3",
+ extendedMIMEType: #"audio/mpeg; codecs="mp3""#
+ ),
+ MusicCandidate(
+ plexContainer: "mp4",
+ plexCodec: "aac",
+ extendedMIMEType: #"audio/mp4; codecs="mp4a.40.2""#
+ ),
+ MusicCandidate(
+ plexContainer: "mp4",
+ plexCodec: "alac",
+ extendedMIMEType: #"audio/mp4; codecs="alac""#
+ ),
+ MusicCandidate(
+ plexContainer: "ogg",
+ plexCodec: "opus",
+ extendedMIMEType: #"audio/ogg; codecs="opus""#
+ ),
+ ]
+
+ private static let hlsAudioCandidates = [
+ AudioCandidate(
+ plexCodec: "ac3",
+ extendedMIMEType: #"video/mp4; codecs="avc1.640028, ac-3""#
+ ),
+ AudioCandidate(
+ plexCodec: "eac3",
+ extendedMIMEType: #"video/mp4; codecs="avc1.640028, ec-3""#
+ ),
+ ]
+}
diff --git a/PlexBar/Support/PlexAPIError.swift b/PlexBar/Support/PlexAPIError.swift
new file mode 100644
index 0000000..5bb39a6
--- /dev/null
+++ b/PlexBar/Support/PlexAPIError.swift
@@ -0,0 +1,82 @@
+import Foundation
+
+enum PlexAPIError: LocalizedError {
+ case invalidServerURL
+ case missingToken
+ case invalidResponse
+ case badStatusCode(Int)
+ case decodingFailed(Error)
+ case missingHistorySeriesIdentity([String])
+ case noPlayableMedia
+ case playbackRejected(String)
+ case missingServerIdentity
+ case invalidPlayQueue
+ case invalidDownloadQueue
+ case missingLibraryProvider
+ case missingLibraryBrowseRoute
+ case missingLibraryContinueWatchingFeature
+ case missingLibraryPromotedFeature
+ case missingLibrarySearchFeature
+ case missingLibraryTimelineFeature
+ case missingLibraryPlayQueueFeature
+ case missingLibraryPlaylistFeature
+ case missingLibraryRateFeature
+ case missingRemoveFromContinueWatchingAction
+ case libraryManagementUnavailable
+ case invalidPersonalRating
+ case invalidMediaTitle
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidServerURL:
+ return "Enter a valid Plex server URL, for example http://192.168.1.10:32400."
+ case .missingToken:
+ return "Add a Plex token before refreshing sessions."
+ case .invalidResponse:
+ return "Plex returned a response that PlexBar could not read."
+ case .badStatusCode(let statusCode):
+ return "Plex returned HTTP \(statusCode). Check the server URL and token."
+ case .decodingFailed:
+ return "Plex returned data in an unexpected format."
+ case .missingHistorySeriesIdentity:
+ return "Plex did not return enough metadata to build watch history charts."
+ case .noPlayableMedia:
+ return "Plex did not return a playable media part."
+ case .playbackRejected(let reason):
+ return "Plex rejected playback: \(reason)"
+ case .missingServerIdentity:
+ return "Plex did not provide the selected server identity required to create a play queue."
+ case .invalidPlayQueue:
+ return "Plex returned a play queue without the selected item."
+ case .invalidDownloadQueue:
+ return "Plex returned an invalid download queue."
+ case .missingLibraryProvider:
+ return "Plex did not advertise its library provider."
+ case .missingLibraryBrowseRoute:
+ return "Plex did not advertise a browse route for this library."
+ case .missingLibraryContinueWatchingFeature:
+ return "Plex did not advertise the unified Continue Watching feed for its library provider."
+ case .missingLibraryPromotedFeature:
+ return "Plex did not advertise the Home feed for its library provider."
+ case .missingLibrarySearchFeature:
+ return "Plex did not advertise search for its library provider."
+ case .missingLibraryTimelineFeature:
+ return
+ "Plex did not advertise the library timeline actions required to update watched status."
+ case .missingLibraryPlayQueueFeature:
+ return "Plex did not advertise play queues for this library."
+ case .missingLibraryPlaylistFeature:
+ return "Plex did not advertise playlists for this library."
+ case .missingLibraryRateFeature:
+ return "Plex did not advertise personal ratings for this library."
+ case .missingRemoveFromContinueWatchingAction:
+ return "Plex did not advertise removal from Continue Watching."
+ case .libraryManagementUnavailable:
+ return "This Plex account cannot manage the selected server library."
+ case .invalidPersonalRating:
+ return "Choose a personal rating from half a star through five stars, or clear the rating."
+ case .invalidMediaTitle:
+ return "Enter a title."
+ }
+ }
+}
diff --git a/PlexBar/Support/PlexActivityRefreshClock.swift b/PlexBar/Support/PlexActivityRefreshClock.swift
new file mode 100644
index 0000000..9309112
--- /dev/null
+++ b/PlexBar/Support/PlexActivityRefreshClock.swift
@@ -0,0 +1,13 @@
+import Foundation
+
+struct PlexActivityRefreshClock: Sendable {
+ var now: @Sendable () -> ContinuousClock.Instant
+ var sleepUntil: @Sendable (ContinuousClock.Instant) async throws -> Void
+
+ static let continuous = PlexActivityRefreshClock(
+ now: { ContinuousClock.now },
+ sleepUntil: { deadline in
+ try await ContinuousClock().sleep(until: deadline, tolerance: .seconds(1))
+ }
+ )
+}
diff --git a/PlexBar/Support/PlexAppRuntime.swift b/PlexBar/Support/PlexAppRuntime.swift
new file mode 100644
index 0000000..64cef41
--- /dev/null
+++ b/PlexBar/Support/PlexAppRuntime.swift
@@ -0,0 +1,136 @@
+import Foundation
+
+@MainActor
+struct PlexAppRuntime {
+ enum Mode: Equatable {
+ case live
+ case mock
+ }
+
+ nonisolated private static let mockArgument = "--mock"
+ private static let mockDefaultsSuiteName = "\(AppConstants.bundleIdentifier).mock"
+
+ let settingsStore: PlexSettingsStore
+ let authClient: PlexAuthClient
+ let apiClient: PlexAPIClient
+ let geoIPClient: PlexGeoIPClient
+ let sessionEventsClient: PlexSessionEventsClient
+ let connectionResolver: PlexConnectionResolver
+ let deviceIdentityStore: any PlexDeviceIdentityProviding
+ let downloadTransferCoordinator: PlexDownloadTransferCoordinator
+ let downloadPackageStore: PlexDownloadPackageStore
+ let downloadJobRegistry: PlexDownloadJobRegistry
+ let offlinePlaybackRegistry: PlexOfflinePlaybackRegistry
+ let downloadPreparedAssetStore: PlexDownloadPreparedAssetStore
+ let playbackBandwidthRegistry: PlexPlaybackBandwidthRegistry
+
+ static func current(processInfo: ProcessInfo = .processInfo) -> PlexAppRuntime {
+ current(arguments: processInfo.arguments)
+ }
+
+ static func current(arguments: [String]) -> PlexAppRuntime {
+ switch mode(arguments: arguments) {
+ case .live:
+ return liveRuntime()
+ case .mock:
+ return mockRuntime()
+ }
+ }
+
+ nonisolated static func mode(arguments: [String]) -> Mode {
+ #if DEBUG
+ if arguments.contains(mockArgument) {
+ return .mock
+ }
+ #else
+ _ = arguments
+ #endif
+
+ return .live
+ }
+
+ nonisolated static func makeImageSession(arguments: [String]) -> URLSession {
+ switch mode(arguments: arguments) {
+ case .live:
+ return .shared
+ case .mock:
+ return PlexDebugMockServer.makeSession()
+ }
+ }
+
+ private static func liveRuntime() -> PlexAppRuntime {
+ let settingsStore = PlexSettingsStore()
+ let authClient = PlexAuthClient()
+ let apiClient = PlexAPIClient()
+ let geoIPClient = PlexGeoIPClient()
+ let sessionEventsClient = PlexSessionEventsClient()
+ let downloadPackageStore = PlexDownloadPackageStore()
+
+ return PlexAppRuntime(
+ settingsStore: settingsStore,
+ authClient: authClient,
+ apiClient: apiClient,
+ geoIPClient: geoIPClient,
+ sessionEventsClient: sessionEventsClient,
+ connectionResolver: PlexConnectionResolver(client: apiClient),
+ deviceIdentityStore: PlexKeychainDeviceIdentityStore(),
+ downloadTransferCoordinator: .live(packageStore: downloadPackageStore),
+ downloadPackageStore: downloadPackageStore,
+ downloadJobRegistry: PlexDownloadJobRegistry(),
+ offlinePlaybackRegistry: PlexOfflinePlaybackRegistry(),
+ downloadPreparedAssetStore: PlexDownloadPreparedAssetStore(),
+ playbackBandwidthRegistry: PlexPlaybackBandwidthRegistry()
+ )
+ }
+
+ private static func mockRuntime() -> PlexAppRuntime {
+ let settingsStore = mockSettingsStore()
+ let session = PlexDebugMockServer.makeSession()
+ let apiClient = PlexAPIClient(session: session)
+
+ let downloadRootURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent(
+ "PlexBarMockDownloads-\(ProcessInfo.processInfo.globallyUniqueString)",
+ isDirectory: true
+ )
+ let downloadPackageStore = PlexDownloadPackageStore(rootURL: downloadRootURL)
+ return PlexAppRuntime(
+ settingsStore: settingsStore,
+ authClient: PlexAuthClient(session: session),
+ apiClient: apiClient,
+ geoIPClient: PlexGeoIPClient(session: session),
+ sessionEventsClient: PlexDebugMockServer.makeEventsClient(),
+ connectionResolver: PlexConnectionResolver(client: apiClient),
+ deviceIdentityStore: PlexMemoryDeviceIdentityStore(),
+ downloadTransferCoordinator: .inert(
+ rootURL: downloadRootURL,
+ packageStore: downloadPackageStore
+ ),
+ downloadPackageStore: downloadPackageStore,
+ downloadJobRegistry: PlexDownloadJobRegistry(rootURL: downloadRootURL),
+ offlinePlaybackRegistry: PlexOfflinePlaybackRegistry(rootURL: downloadRootURL),
+ downloadPreparedAssetStore: PlexDownloadPreparedAssetStore(rootURL: downloadRootURL),
+ playbackBandwidthRegistry: PlexPlaybackBandwidthRegistry(
+ rootURL: downloadRootURL.appendingPathComponent("Playback", isDirectory: true)
+ )
+ )
+ }
+
+ private static func mockSettingsStore() -> PlexSettingsStore {
+ let defaults = UserDefaults(suiteName: mockDefaultsSuiteName) ?? .standard
+ defaults.removePersistentDomain(forName: mockDefaultsSuiteName)
+ let credentials = PlexStoredCredentials(
+ userToken: PlexDebugMockServer.mockUserToken,
+ serverToken: PlexDebugMockServer.mockServer.accessToken
+ )
+
+ let settingsStore = PlexSettingsStore(
+ defaults: defaults,
+ credentialStore: PlexMemoryCredentialStore(credentials: credentials),
+ initialCredentials: credentials
+ )
+ settingsStore.saveServerSelection(PlexDebugMockServer.mockServer)
+ settingsStore.saveResolvedConnection(PlexDebugMockServer.mockResolvedConnection)
+ return settingsStore
+ }
+}
diff --git a/Sources/PlexBar/Support/PlexArtworkPalette.swift b/PlexBar/Support/PlexArtworkPalette.swift
similarity index 100%
rename from Sources/PlexBar/Support/PlexArtworkPalette.swift
rename to PlexBar/Support/PlexArtworkPalette.swift
diff --git a/PlexBar/Support/PlexArtworkPrefetcher.swift b/PlexBar/Support/PlexArtworkPrefetcher.swift
new file mode 100644
index 0000000..c693485
--- /dev/null
+++ b/PlexBar/Support/PlexArtworkPrefetcher.swift
@@ -0,0 +1,75 @@
+import Foundation
+
+struct PlexArtworkPrefetchRequest: Hashable, Sendable {
+ let candidateURLs: [URL]
+ let token: String
+ let clientContext: PlexClientContext
+ let maximumPixelSize: Int
+}
+
+actor PlexArtworkPrefetcher {
+ static let shared = PlexArtworkPrefetcher()
+
+ private let imageClient: PlexImageClient
+ private let maximumConcurrentRequests: Int
+ private let maximumQueuedRequests: Int
+ private var activeRequests: Set = []
+ private var queue: [PlexArtworkPrefetchRequest] = []
+
+ init(
+ imageClient: PlexImageClient = PlexImageClient(),
+ maximumConcurrentRequests: Int = 4,
+ maximumQueuedRequests: Int = 24
+ ) {
+ self.imageClient = imageClient
+ self.maximumConcurrentRequests = max(1, maximumConcurrentRequests)
+ self.maximumQueuedRequests = max(0, maximumQueuedRequests)
+ }
+
+ func prefetch(_ requests: [PlexArtworkPrefetchRequest]) {
+ var prioritizedRequests: [PlexArtworkPrefetchRequest] = []
+ for request in requests where !prioritizedRequests.contains(request) {
+ guard !activeRequests.contains(request),
+ imageClient.cachedCGImageResult(
+ from: request.candidateURLs,
+ token: request.token,
+ maximumPixelSize: request.maximumPixelSize
+ ) == nil else {
+ continue
+ }
+
+ prioritizedRequests.append(request)
+ }
+
+ let prioritizedSet = Set(prioritizedRequests)
+ queue.removeAll { prioritizedSet.contains($0) }
+ queue.insert(contentsOf: prioritizedRequests, at: 0)
+ if queue.count > maximumQueuedRequests {
+ queue.removeLast(queue.count - maximumQueuedRequests)
+ }
+ startQueuedRequests()
+ }
+
+ private func startQueuedRequests() {
+ while activeRequests.count < maximumConcurrentRequests,
+ let request = queue.first {
+ queue.removeFirst()
+ activeRequests.insert(request)
+
+ Task(priority: .utility) { [imageClient] in
+ _ = await imageClient.fetchCGImageResult(
+ from: request.candidateURLs,
+ token: request.token,
+ clientContext: request.clientContext,
+ maximumPixelSize: request.maximumPixelSize
+ )
+ requestDidFinish(request)
+ }
+ }
+ }
+
+ private func requestDidFinish(_ request: PlexArtworkPrefetchRequest) {
+ activeRequests.remove(request)
+ startQueuedRequests()
+ }
+}
diff --git a/Sources/PlexBar/Support/PlexArtworkPresentationState.swift b/PlexBar/Support/PlexArtworkPresentationState.swift
similarity index 50%
rename from Sources/PlexBar/Support/PlexArtworkPresentationState.swift
rename to PlexBar/Support/PlexArtworkPresentationState.swift
index 9d61972..46f2edc 100644
--- a/Sources/PlexBar/Support/PlexArtworkPresentationState.swift
+++ b/PlexBar/Support/PlexArtworkPresentationState.swift
@@ -10,11 +10,13 @@ private struct SendableCGImageBox: @unchecked Sendable {
@Observable
final class PlexArtworkPresentationState {
private let imageClient: PlexImageClient
- private let paletteExtractor: PlexArtworkPaletteExtractor
+ private let extractPalette: @Sendable (CGImage) -> PlexArtworkPalette?
private let primaryImageURL: URL?
private let fallbackImageURL: URL?
private let token: String
private let wantsPalette: Bool
+ private let maximumPixelSize: Int?
+ private var loadGeneration = 0
private(set) var cgImage: CGImage?
private(set) var palette: PlexArtworkPalette?
@@ -25,15 +27,19 @@ final class PlexArtworkPresentationState {
fallbackImageURL: URL? = nil,
token: String = "",
wantsPalette: Bool = false,
+ maximumPixelSize: Int? = nil,
imageClient: PlexImageClient = PlexImageClient(),
- paletteExtractor: PlexArtworkPaletteExtractor = PlexArtworkPaletteExtractor()
+ extractPalette: @escaping @Sendable (CGImage) -> PlexArtworkPalette? = {
+ PlexArtworkPaletteExtractor().extract(from: $0)
+ }
) {
self.primaryImageURL = primaryImageURL
self.fallbackImageURL = fallbackImageURL
self.token = token
self.wantsPalette = wantsPalette
+ self.maximumPixelSize = maximumPixelSize
self.imageClient = imageClient
- self.paletteExtractor = paletteExtractor
+ self.extractPalette = extractPalette
hydrateFromCache()
}
@@ -47,8 +53,15 @@ final class PlexArtworkPresentationState {
fallbackImageURL: URL?,
token: String,
clientContext: PlexClientContext,
- wantsPalette: Bool
+ wantsPalette: Bool,
+ maximumPixelSize: Int? = nil
) async {
+ guard !Task.isCancelled else {
+ return
+ }
+
+ loadGeneration &+= 1
+ let generation = loadGeneration
let candidateURLs = [primaryImageURL, fallbackImageURL].compactMap { $0 }
guard !candidateURLs.isEmpty else {
cgImage = nil
@@ -57,9 +70,35 @@ final class PlexArtworkPresentationState {
return
}
- if let cachedImage = imageClient.cachedCGImageResult(from: candidateURLs, token: token) {
+ if let cachedImage = imageClient.cachedCGImageResult(
+ from: candidateURLs,
+ token: token,
+ maximumPixelSize: maximumPixelSize
+ ) {
cgImage = cachedImage.image
- palette = wantsPalette ? await resolvedPalette(for: cachedImage.image, sourceURL: cachedImage.sourceURL, token: token) : nil
+ guard wantsPalette else {
+ palette = nil
+ isLoading = false
+ return
+ }
+
+ if let cachedPalette = imageClient.cachedPalette(for: cachedImage.sourceURL, token: token) {
+ palette = cachedPalette
+ isLoading = false
+ return
+ }
+
+ palette = nil
+ isLoading = true
+ let resolvedPalette = await resolvedPalette(
+ for: cachedImage.image,
+ sourceURL: cachedImage.sourceURL,
+ token: token
+ )
+ guard isCurrentLoad(generation) else {
+ return
+ }
+ palette = resolvedPalette
isLoading = false
return
}
@@ -76,15 +115,34 @@ final class PlexArtworkPresentationState {
if let loadedImage = await imageClient.fetchCGImageResult(
from: candidateURLs,
token: token,
- clientContext: clientContext
+ clientContext: clientContext,
+ maximumPixelSize: maximumPixelSize
) {
+ guard isCurrentLoad(generation) else {
+ return
+ }
cgImage = loadedImage.image
- palette = wantsPalette ? await resolvedPalette(for: loadedImage.image, sourceURL: loadedImage.sourceURL, token: token) : nil
+ guard wantsPalette else {
+ isLoading = false
+ return
+ }
+
+ let resolvedPalette = await resolvedPalette(
+ for: loadedImage.image,
+ sourceURL: loadedImage.sourceURL,
+ token: token
+ )
+ guard isCurrentLoad(generation) else {
+ return
+ }
+ palette = resolvedPalette
isLoading = false
return
}
- isLoading = false
+ if isCurrentLoad(generation) {
+ isLoading = false
+ }
}
private func resolvedPalette(for image: CGImage, sourceURL: URL, token: String) async -> PlexArtworkPalette? {
@@ -92,13 +150,12 @@ final class PlexArtworkPresentationState {
return cachedPalette
}
- let extractor = paletteExtractor
+ let extractPalette = extractPalette
let imageBox = SendableCGImageBox(image: image)
- let extractionTask = Task.detached(priority: .userInitiated) {
- extractor.extract(from: imageBox.image)
- }
-
- guard let extractedPalette = await extractionTask.value else {
+ guard let extractedPalette = await extractArtworkPalette(
+ imageBox: imageBox,
+ extractPalette: extractPalette
+ ) else {
return nil
}
@@ -106,13 +163,21 @@ final class PlexArtworkPresentationState {
return extractedPalette
}
+ private func isCurrentLoad(_ generation: Int) -> Bool {
+ !Task.isCancelled && generation == loadGeneration
+ }
+
private func hydrateFromCache() {
let candidateURLs = [primaryImageURL, fallbackImageURL].compactMap { $0 }
guard !candidateURLs.isEmpty else {
return
}
- guard let cachedImage = imageClient.cachedCGImageResult(from: candidateURLs, token: token) else {
+ guard let cachedImage = imageClient.cachedCGImageResult(
+ from: candidateURLs,
+ token: token,
+ maximumPixelSize: maximumPixelSize
+ ) else {
return
}
@@ -122,3 +187,11 @@ final class PlexArtworkPresentationState {
: nil
}
}
+
+@concurrent
+private func extractArtworkPalette(
+ imageBox: SendableCGImageBox,
+ extractPalette: @escaping @Sendable (CGImage) -> PlexArtworkPalette?
+) async -> PlexArtworkPalette? {
+ extractPalette(imageBox.image)
+}
diff --git a/PlexBar/Support/PlexAutoplayPreferences.swift b/PlexBar/Support/PlexAutoplayPreferences.swift
new file mode 100644
index 0000000..9d0671c
--- /dev/null
+++ b/PlexBar/Support/PlexAutoplayPreferences.swift
@@ -0,0 +1,132 @@
+import Foundation
+#if canImport(AppKit)
+import AppKit
+#endif
+
+enum PlexCinemaPreplayPreference: Int, CaseIterable, Identifiable, Sendable {
+ case off = -1
+ case preRollOnly = 0
+ case oneTrailer = 1
+ case twoTrailers = 2
+ case threeTrailers = 3
+ case fourTrailers = 4
+ case fiveTrailers = 5
+
+ var id: Self { self }
+
+ var label: String {
+ switch self {
+ case .off: "Off"
+ case .preRollOnly: "Pre-roll Only"
+ case .oneTrailer: "1 Trailer"
+ case .twoTrailers: "2 Trailers"
+ case .threeTrailers: "3 Trailers"
+ case .fourTrailers: "4 Trailers"
+ case .fiveTrailers: "5 Trailers"
+ }
+ }
+
+ /// `nil` omits the PMS parameter and therefore disables both trailers and
+ /// the configured server pre-roll. Zero is intentionally distinct: PMS
+ /// returns the pre-roll without prepending a trailer.
+ var extrasPrefixCount: Int? {
+ self == .off ? nil : rawValue
+ }
+}
+
+enum PlexAutoplayCountdown: Int, CaseIterable, Identifiable, Sendable {
+ case immediate = 0
+ case fiveSeconds = 5
+ case tenSeconds = 10
+ case fifteenSeconds = 15
+ case thirtySeconds = 30
+ case sixtySeconds = 60
+
+ var id: Self { self }
+
+ var label: String {
+ switch self {
+ case .immediate: "Immediately"
+ case .fiveSeconds: "5 Seconds"
+ case .tenSeconds: "10 Seconds"
+ case .fifteenSeconds: "15 Seconds"
+ case .thirtySeconds: "30 Seconds"
+ case .sixtySeconds: "60 Seconds"
+ }
+ }
+}
+
+enum PlexPassoutProtection: Int, CaseIterable, Identifiable, Sendable {
+ case never = 0
+ case oneHour = 3_600
+ case twoHours = 7_200
+ case threeHours = 10_800
+
+ var id: Self { self }
+
+ var label: String {
+ switch self {
+ case .never: "Never"
+ case .oneHour: "1 Hour"
+ case .twoHours: "2 Hours"
+ case .threeHours: "3 Hours"
+ }
+ }
+
+ var interval: TimeInterval? {
+ self == .never ? nil : TimeInterval(rawValue)
+ }
+}
+
+struct PlexAutoplayPreferences: Equatable, Sendable {
+ let isEnabled: Bool
+ let countdown: PlexAutoplayCountdown
+ let passoutProtection: PlexPassoutProtection
+}
+
+@MainActor
+final class PlexUserInteractionStore {
+ private(set) var lastInteractionDate: Date
+
+ init(lastInteractionDate: Date = Date()) {
+ self.lastInteractionDate = lastInteractionDate
+ }
+
+ func recordInteraction(at date: Date = Date()) {
+ lastInteractionDate = date
+ }
+}
+
+#if canImport(AppKit)
+@MainActor
+final class PlexUserInteractionMonitor {
+ nonisolated(unsafe) private var eventMonitor: Any?
+
+ init(store: PlexUserInteractionStore) {
+ eventMonitor = NSEvent.addLocalMonitorForEvents(
+ matching: [
+ .keyDown,
+ .leftMouseDown,
+ .rightMouseDown,
+ .otherMouseDown,
+ .scrollWheel,
+ .gesture,
+ .magnify,
+ .swipe,
+ .rotate,
+ .beginGesture,
+ .endGesture,
+ ]
+ ) { [weak store] event in
+ store?.recordInteraction()
+ return event
+ }
+ }
+
+ deinit {
+ if let eventMonitor {
+ NSEvent.removeMonitor(eventMonitor)
+ }
+ }
+}
+#endif
diff --git a/PlexBar/Support/PlexBoundedConcurrentMap.swift b/PlexBar/Support/PlexBoundedConcurrentMap.swift
new file mode 100644
index 0000000..e60af34
--- /dev/null
+++ b/PlexBar/Support/PlexBoundedConcurrentMap.swift
@@ -0,0 +1,42 @@
+import Foundation
+
+enum PlexBoundedConcurrentMap {
+ static func compactMap(
+ _ inputs: [Input],
+ maximumConcurrentTasks: Int,
+ transform: @escaping @Sendable (Input) async -> Output?
+ ) async -> [Output] {
+ guard !inputs.isEmpty else { return [] }
+
+ let taskLimit = min(max(maximumConcurrentTasks, 1), inputs.count)
+ return await withTaskGroup(
+ of: (index: Int, output: Output?).self,
+ returning: [Output].self
+ ) { group in
+ for index in 0.. String) {
+ for (position, person) in people.enumerated() {
+ guard let name = person.tag.nilIfBlank else {
+ continue
+ }
+ let subtitle = credit(person)
+ let exactIdentity = person.tagKey?.nilIfBlank.map { "tag-key:\($0)" }
+ ?? person.id.map { "id:\($0)" }
+
+ if let exactIdentity, let existingIndex = indexByIdentity[exactIdentity] {
+ builders[existingIndex].appendCredit(subtitle)
+ builders[existingIndex].fillMissingPortrait(person.thumb)
+ continue
+ }
+
+ let identity = exactIdentity
+ ?? "anonymous:\(builders.count):\(position):\(name):\(subtitle)"
+ indexByIdentity[identity] = builders.count
+ builders.append(CreditBuilder(
+ id: "\(scope):\(identity)",
+ person: person,
+ name: name,
+ credit: subtitle
+ ))
+ }
+ }
+
+ var credits: [PlexCastAndCrewCredit] {
+ builders.map(\.credit)
+ }
+}
+
+private struct CreditBuilder {
+ let id: String
+ var person: PlexTag
+ let name: String
+ private var creditLabels: [String]
+
+ init(id: String, person: PlexTag, name: String, credit: String) {
+ self.id = id
+ self.person = person
+ self.name = name
+ creditLabels = [credit]
+ }
+
+ mutating func appendCredit(_ credit: String) {
+ guard !creditLabels.contains(credit) else {
+ return
+ }
+ creditLabels.append(credit)
+ }
+
+ mutating func fillMissingPortrait(_ thumb: String?) {
+ guard person.thumb?.nilIfBlank == nil, thumb?.nilIfBlank != nil else {
+ return
+ }
+ person = PlexTag.copying(person, thumb: thumb)
+ }
+
+ var credit: PlexCastAndCrewCredit {
+ let subtitle = creditLabels.joined(separator: " · ")
+ return PlexCastAndCrewCredit(
+ id: id,
+ name: name,
+ subtitle: subtitle,
+ thumb: person.thumb?.nilIfBlank,
+ route: PlexPersonRoute(person: person)
+ )
+ }
+}
+
+private extension PlexTag {
+ static func copying(_ person: PlexTag, thumb: String?) -> PlexTag {
+ PlexTag(
+ id: person.id,
+ tag: person.tag,
+ tagKey: person.tagKey,
+ tagType: person.tagType,
+ filter: person.filter,
+ role: person.role,
+ thumb: thumb,
+ order: person.order
+ )
+ }
+}
diff --git a/Sources/PlexBar/Support/PlexClientContext.swift b/PlexBar/Support/PlexClientContext.swift
similarity index 50%
rename from Sources/PlexBar/Support/PlexClientContext.swift
rename to PlexBar/Support/PlexClientContext.swift
index da5df6f..6835a39 100644
--- a/Sources/PlexBar/Support/PlexClientContext.swift
+++ b/PlexBar/Support/PlexClientContext.swift
@@ -1,17 +1,51 @@
import Foundation
-struct PlexClientContext {
+struct PlexClientContext: Hashable, Sendable {
+ static let pmsAPIVersion = "1.0.0"
+
let clientIdentifier: String
+ let product: String
+ let productVersion: String
+ let platform: String
+ let device: String
+ let deviceName: String
+
+ init(clientIdentifier: String) {
+ self.init(
+ clientIdentifier: clientIdentifier,
+ product: AppConstants.appName,
+ productVersion: AppConstants.productVersion,
+ platform: Self.currentPlatform,
+ device: Self.currentDevice,
+ deviceName: "\(Self.currentDevice) (\(AppConstants.appName))"
+ )
+ }
+
+ init(
+ clientIdentifier: String,
+ product: String,
+ productVersion: String,
+ platform: String,
+ device: String,
+ deviceName: String
+ ) {
+ self.clientIdentifier = clientIdentifier
+ self.product = product
+ self.productVersion = productVersion
+ self.platform = platform
+ self.device = device
+ self.deviceName = deviceName
+ }
var headers: [String: String] {
[
"X-Plex-Client-Identifier": clientIdentifier,
- "X-Plex-Product": AppConstants.appName,
- "X-Plex-Version": AppConstants.productVersion,
- "X-Plex-Platform": "macOS",
+ "X-Plex-Product": product,
+ "X-Plex-Version": productVersion,
+ "X-Plex-Platform": platform,
"X-Plex-Platform-Version": platformVersion,
- "X-Plex-Device": "Mac",
- "X-Plex-Device-Name": "Mac (\(AppConstants.appName))",
+ "X-Plex-Device": device,
+ "X-Plex-Device-Name": deviceName,
"X-Plex-Language": "en",
]
}
@@ -42,4 +76,20 @@ struct PlexClientContext {
let version = ProcessInfo.processInfo.operatingSystemVersion
return "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)"
}
+
+ private static var currentPlatform: String {
+ #if os(tvOS)
+ "tvOS"
+ #else
+ "macOS"
+ #endif
+ }
+
+ private static var currentDevice: String {
+ #if os(tvOS)
+ "Apple TV"
+ #else
+ "Mac"
+ #endif
+ }
}
diff --git a/Sources/PlexBar/Support/PlexConnectionTransport.swift b/PlexBar/Support/PlexConnectionTransport.swift
similarity index 88%
rename from Sources/PlexBar/Support/PlexConnectionTransport.swift
rename to PlexBar/Support/PlexConnectionTransport.swift
index 162c344..283b176 100644
--- a/Sources/PlexBar/Support/PlexConnectionTransport.swift
+++ b/PlexBar/Support/PlexConnectionTransport.swift
@@ -16,9 +16,13 @@ enum PlexConnectionStoreError: LocalizedError {
extension Error {
var isPlexConnectivityFailure: Bool {
+ plexConnectivityFailureCode != nil
+ }
+
+ var plexConnectivityFailureCode: URLError.Code? {
let urlError = (self as? URLError) ?? (self as NSError).userInfo[NSUnderlyingErrorKey] as? URLError
guard let urlError else {
- return false
+ return nil
}
switch urlError.code {
@@ -42,9 +46,9 @@ extension Error {
.cannotCloseFile,
.cannotWriteToFile,
.timedOut:
- return true
+ return urlError.code
default:
- return false
+ return nil
}
}
}
diff --git a/PlexBar/Support/PlexDebugMockServer.swift b/PlexBar/Support/PlexDebugMockServer.swift
new file mode 100644
index 0000000..b6cd465
--- /dev/null
+++ b/PlexBar/Support/PlexDebugMockServer.swift
@@ -0,0 +1,1513 @@
+import PlexModels
+import PlexMockData
+import Foundation
+
+enum PlexDebugMockServer {
+ static var mockUserToken: String {
+ #if DEBUG
+ return debugFixture.userToken
+ #else
+ preconditionFailure("Mock runtime is only available in DEBUG builds.")
+ #endif
+ }
+
+ static var mockServer: PlexServerResource {
+ #if DEBUG
+ return debugFixture.server
+ #else
+ preconditionFailure("Mock runtime is only available in DEBUG builds.")
+ #endif
+ }
+
+ static var mockResolvedConnection: PlexResolvedConnection {
+ #if DEBUG
+ return debugFixture.activeConnection
+ #else
+ preconditionFailure("Mock runtime is only available in DEBUG builds.")
+ #endif
+ }
+
+ static func makeSession() -> URLSession {
+ #if DEBUG
+ let stateID = PlexDebugMockStateRegistry.shared.register(PlexDebugMockState())
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.httpAdditionalHeaders = [PlexDebugMockStateRegistry.headerName: stateID]
+ configuration.protocolClasses = [PlexDebugMockURLProtocol.self]
+ return URLSession(configuration: configuration)
+ #else
+ return .shared
+ #endif
+ }
+
+ static func makeEventsClient(liveClient: PlexSessionEventsClient = PlexSessionEventsClient()) -> PlexSessionEventsClient {
+ #if DEBUG
+ return PlexSessionEventsClient { configuration, onEvent in
+ guard configuration.serverURL.host == debugFixture.server.connections[0].uri.host else {
+ try await liveClient.monitor(using: configuration, onEvent: onEvent)
+ return
+ }
+
+ try await onEvent(.connected)
+
+ while !Task.isCancelled {
+ try await Task.sleep(for: .seconds(3_600))
+ }
+
+ throw CancellationError()
+ }
+ #else
+ return liveClient
+ #endif
+ }
+
+}
+
+#if DEBUG
+private let debugFixture = PlexDebugMockFixture.makeDefault()
+
+private struct PlexDebugMockFixture {
+ let userToken: String
+ let authenticatedUser: PlexAuthenticatedUser
+ let server: PlexServerResource
+ let activeConnection: PlexResolvedConnection
+ let sessions: [PlexSession]
+ let streamLevelsByID: [Int: [Double]]
+ let resolvedLocationsByIPAddress: [String: String]
+ let historyItems: [PlexHistoryItem]
+ let catalog: PlexMockMediaCatalog
+ let accountsByID: [Int: PlexAccount]
+ let historyDevices: [PlexHistoryDevice]
+ let librarySections: [PlexDebugMockLibrarySection]
+ let snapshotDate: Date
+ let artwork: [DebugMockArtwork]
+
+ var libraries: [PlexLibrary] {
+ librarySections.map(\.library)
+ }
+
+ static func makeDefault() -> PlexDebugMockFixture {
+ let payload = try! PlexMockServerPayload.loadDefault()
+ let snapshotDate = Date()
+ let userToken = "eyJhbGciOiJFZERTQSIsImtpZCI6Im1vY2siLCJ0eXAiOiJKV1QifQ."
+ + "eyJleHAiOjQxMDI0NDQ4MDAsInVzZXJuYW1lIjoibW9jayIsImVtYWlsIjoibW9ja0BleGFtcGxlLmNvbSIs"
+ + "ImZyaWVuZGx5X25hbWUiOiJNb2NrIn0.mock-signature"
+ let server = payload.server.materialize()
+ let serverURL = server.connections[0].uri
+ let usersByID = Dictionary(uniqueKeysWithValues: payload.users.map { ($0.id, $0) })
+ let catalog = try! PlexMockMediaCatalog.loadDefault()
+ precondition(payload.libraries.allSatisfy { library in
+ library.entries.allSatisfy { catalog.record(for: $0.mediaID)?.item.type == library.type }
+ }, "Every mock library root must resolve to the declared media type")
+
+ let activeConnection = PlexResolvedConnection(
+ serverID: server.id,
+ url: serverURL,
+ kind: .local,
+ validatedAt: snapshotDate
+ )
+ guard let authenticatedProfile = usersByID[payload.authenticatedUserID] else {
+ preconditionFailure("Missing authenticated mock user")
+ }
+ let avatarResource = payload.artwork.first { $0.path == authenticatedProfile.avatar }
+ let authenticatedUser = authenticatedProfile.materializeAuthenticatedUser(
+ thumbOverride: avatarResource.map { PlexMockServerResourceLocator.url(for: $0.resource).absoluteString }
+ )
+ let devices = payload.users.flatMap(\.devices)
+ let locationsByIP = devices.reduce(into: [String: String]()) { locations, device in
+ if let ip = device.connection.remotePublicAddress, let location = device.connection.resolvedLocation {
+ locations[ip] = location
+ }
+ }
+
+ let accountsByID = Dictionary(
+ uniqueKeysWithValues: payload.users.map { userPayload in
+ let account = userPayload.materialize()
+ return (account.id, account)
+ }
+ )
+ let sessions = payload.activeSessions.map {
+ materializeSession($0, usersByID: usersByID, catalog: catalog)
+ }
+ let historyItems = payload.historyEvents.map {
+ materializeHistoryItem($0, referenceDate: snapshotDate, catalog: catalog)
+ }
+ let librarySections = payload.libraries.map {
+ materializeLibrarySection($0, referenceDate: snapshotDate, catalog: catalog)
+ }
+
+ return PlexDebugMockFixture(
+ userToken: userToken,
+ authenticatedUser: authenticatedUser,
+ server: server,
+ activeConnection: activeConnection,
+ sessions: sessions,
+ streamLevelsByID: Dictionary(
+ payload.activeSessions.compactMap { session in
+ session.audioStream.map { ($0.id, $0.levels) }
+ },
+ uniquingKeysWith: { existing, _ in existing }
+ ),
+ resolvedLocationsByIPAddress: locationsByIP,
+ historyItems: historyItems,
+ catalog: catalog,
+ accountsByID: accountsByID,
+ historyDevices: devices.map { $0.materializeHistoryDevice() }.sorted { $0.id < $1.id },
+ librarySections: librarySections,
+ snapshotDate: snapshotDate,
+ artwork: payload.artwork.map {
+ DebugMockArtwork.load(serverURL: serverURL, mockPath: $0.path, resource: $0.resource)
+ }
+ )
+ }
+
+ func response(for request: URLRequest, state: PlexDebugMockState) -> PlexDebugMockResponse? {
+ guard let url = request.url else {
+ return nil
+ }
+
+ if isMockServer(url) {
+ return serverResponse(for: request, state: state)
+ }
+
+ if PlexRemoteService.isPlexHosted(url) {
+ return remoteResponse(for: request)
+ }
+
+ return nil
+ }
+
+ private func isMockServer(_ url: URL) -> Bool {
+ let fixtureURL = activeConnection.url
+ return url.scheme == fixtureURL.scheme && url.host == fixtureURL.host && url.port == fixtureURL.port
+ }
+
+ private func serverResponse(for request: URLRequest, state: PlexDebugMockState) -> PlexDebugMockResponse? {
+ guard let url = request.url else {
+ return nil
+ }
+
+ let method = request.httpMethod ?? "GET"
+ let isTermination = url.path == "/status/sessions/terminate" && method == "POST"
+ guard (method == "GET" && url.path != "/status/sessions/terminate") || isTermination else {
+ return errorResponse(for: request, status: 405)
+ }
+
+ if url.path == "/photo/:/transcode" {
+ return transcodedImageResponse(for: url)
+ }
+
+ if url.path.hasPrefix("/mock/avatars/") || url.path.hasPrefix("/mock/art/") {
+ return imageResponse(for: url)
+ }
+
+ if url.path == "/identity" {
+ return jsonResponse(
+ url: url,
+ object: [
+ "MediaContainer": [
+ "claimed": true,
+ "machineIdentifier": server.id,
+ "version": server.productVersion ?? ""
+ ]
+ ]
+ )
+ }
+
+ if url.path == "/status/sessions" {
+ let sessionKey = URLComponents(url: url, resolvingAgainstBaseURL: false)?
+ .queryItems?
+ .first(where: { $0.name == "sessionKey" })?
+ .value
+ let filteredSessions = sessions.filter { session in
+ guard state.isTerminated(session) == false else {
+ return false
+ }
+
+ guard let sessionKey else {
+ return true
+ }
+
+ return session.canonicalSessionKey == sessionKey
+ }
+ return jsonResponse(
+ url: url,
+ object: [
+ "MediaContainer": [
+ "Metadata": filteredSessions.map { sessionObject(from: $0) }
+ ]
+ ]
+ )
+ }
+
+ if url.path == "/status/sessions/terminate" {
+ if let sessionID = URLComponents(url: url, resolvingAgainstBaseURL: false)?
+ .queryItems?
+ .first(where: { $0.name == "sessionId" })?
+ .value {
+ state.terminateSession(withID: sessionID)
+ }
+
+ return jsonResponse(url: url, object: ["MediaContainer": [:]])
+ }
+
+ if let streamID = streamID(forLevelsPath: url.path),
+ let levels = streamLevelsByID[streamID] {
+ return jsonResponse(
+ url: url,
+ object: [
+ "MediaContainer": [
+ "size": levels.count,
+ "totalSamples": String(levels.count),
+ "Level": levels.map { ["v": $0] }
+ ]
+ ]
+ )
+ }
+
+ if url.path == "/status/sessions/history/all" {
+ let query = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
+ let id = query.first { $0.name == "metadataItemID" }?.value
+ let cutoff = query.first { $0.name == "viewedAt>" }?.value.flatMap(Double.init)
+ let items = historyItems.compactMap { history -> (item: PlexHistoryItem, date: Date)? in
+ guard let viewedAt = history.viewedAt else { return nil }
+ let media = history.ratingKey.flatMap { catalog.record(for: $0)?.item }
+ let matchesID = id.map {
+ [media?.ratingKey, media?.parentRatingKey, media?.grandparentRatingKey].contains($0)
+ } ?? true
+ let matchesDate = cutoff.map { viewedAt.timeIntervalSince1970 > $0 } ?? true
+ return matchesID && matchesDate ? (history, viewedAt) : nil
+ }.sorted { $0.date > $1.date }
+ return pagedMetadataResponse(
+ for: request,
+ metadata: items.map { historyItemObject(from: $0.item) }
+ )
+ }
+
+ if let response = catalogResponse(for: request) {
+ return response
+ }
+
+ if url.path == "/statistics/media" {
+ let accounts = accountsByID.keys.sorted().compactMap { accountsByID[$0] }.map { accountObject(from: $0) }
+ return jsonResponse(
+ url: url,
+ object: [
+ "MediaContainer": [
+ "Account": accounts,
+ "Device": historyDevices.map { device in
+ compactObject(["id": device.id, "name": device.name, "platform": device.platform])
+ }
+ ]
+ ]
+ )
+ }
+
+ if url.path == "/library/sections/all" {
+ return jsonResponse(
+ url: url,
+ object: [
+ "MediaContainer": [
+ "Directory": librarySections.map { libraryDirectoryObject(from: $0) }
+ ]
+ ]
+ )
+ }
+
+ if url.path == "/media/providers" {
+ return jsonResponse(
+ url: url,
+ object: [
+ "MediaContainer": [
+ "MediaProvider": [[
+ "identifier": "com.plexapp.plugins.library",
+ "Feature": [
+ [
+ "type": "content",
+ "key": "/library/sections",
+ "Directory": librarySections.compactMap {
+ libraryProviderDirectoryObject(from: $0)
+ },
+ ],
+ ["type": "promoted", "key": "/hubs/promoted"],
+ ["type": "continuewatching", "key": "/hubs/continueWatching"],
+ ["type": "search", "key": "/hubs/search"],
+ ["type": "playlist", "key": "/playlists", "readOnly": true],
+ ],
+ ]]
+ ]
+ ]
+ )
+ }
+
+ if url.path == "/hubs/promoted" {
+ let count = URLComponents(url: url, resolvingAgainstBaseURL: false)?
+ .queryItems?
+ .first(where: { $0.name == "count" })?
+ .value
+ .flatMap(Int.init) ?? 20
+ let hubs = promotedHubObjects(itemLimit: max(count, 1))
+ return jsonResponse(
+ url: url,
+ object: [
+ "MediaContainer": [
+ "size": hubs.count,
+ "Hub": hubs
+ ]
+ ]
+ )
+ }
+
+ if url.path == "/hubs/home/recentlyAdded",
+ let librarySection = homeHubLibrarySection(for: url) {
+ return pagedMetadataResponse(
+ for: request,
+ metadata: librarySection.recentItems.map {
+ browseItemObject(from: $0, in: librarySection)
+ }
+ )
+ }
+
+ if let libraryID = collectionsLibraryID(for: url.path),
+ let librarySection = librarySections.first(where: { $0.library.id == libraryID }),
+ let collectionID = mockCollectionID(for: libraryID) {
+ return pagedMetadataResponse(
+ for: request,
+ metadata: [collectionObject(from: librarySection, collectionID: collectionID)]
+ )
+ }
+
+ if let collectionID = collectionItemsID(for: url.path),
+ let librarySection = librarySection(forMockCollectionID: collectionID) {
+ return pagedMetadataResponse(
+ for: request,
+ metadata: librarySection.recentItems.map {
+ browseItemObject(from: $0, in: librarySection)
+ }
+ )
+ }
+
+ if url.path == "/playlists" {
+ return pagedMetadataResponse(for: request, metadata: playlistObjects())
+ }
+
+ if let playlistID = playlistItemsID(for: url.path),
+ let librarySection = librarySection(forMockPlaylistID: playlistID) {
+ return pagedMetadataResponse(
+ for: request,
+ metadata: playlistRecords(in: librarySection).enumerated().map { index, item in
+ var object = browseItemObject(from: item, in: librarySection)
+ object["playlistItemID"] = "\(playlistID)-\(index + 1)"
+ return object
+ }
+ )
+ }
+
+ if let libraryID = libraryDescriptorID(for: url.path, descriptor: "filters"),
+ let librarySection = librarySections.first(where: { $0.library.id == libraryID }) {
+ return jsonResponse(
+ url: url,
+ object: libraryFiltersObject(from: librarySection)
+ )
+ }
+
+ if let libraryID = libraryDescriptorID(for: url.path, descriptor: "sorts"),
+ let librarySection = librarySections.first(where: { $0.library.id == libraryID }) {
+ return jsonResponse(
+ url: url,
+ object: librarySortsObject(from: librarySection)
+ )
+ }
+
+ if let libraryID = libraryDetailsID(for: url.path),
+ let librarySection = librarySections.first(where: { $0.library.id == libraryID }) {
+ return jsonResponse(
+ url: url,
+ object: libraryBrowseDefinitionObject(from: librarySection)
+ )
+ }
+
+ if let libraryID = libraryID(for: url.path),
+ let section = librarySections.first(where: { $0.library.id == libraryID }) {
+ let query = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
+ let requestedType = query.first { $0.name == "type" }?.value.flatMap(Int.init)
+ let typeNames = [1: "movie", 2: "show", 3: "season", 4: "episode", 8: "artist", 9: "album", 10: "track"]
+ let type = requestedType.flatMap { typeNames[$0] } ?? section.rawType
+ let title = query.first { $0.name == "title" }?.value?.nilIfBlank
+ let unwatched = query.contains { $0.name == "unwatched" && $0.value == "1" }
+ let inProgress = query.contains { $0.name == "inProgress" && $0.value == "1" }
+ let records = catalog.records.filter { record in
+ let item = record.item
+ return item.librarySectionID == libraryID && item.type == type
+ && (title.map { item.title.localizedStandardContains($0) } ?? true)
+ && (!unwatched || !item.isWatched)
+ && (!inProgress || hasProgress(record))
+ }
+ let sorted = sortedLibraryItems(records, sort: query.first { $0.name == "sort" }?.value)
+ return pagedMetadataResponse(for: request, metadata: sorted.map { $0.object(referenceDate: snapshotDate) })
+ }
+
+ return nil
+ }
+
+ func errorResponse(for request: URLRequest, status: Int) -> PlexDebugMockResponse {
+ let url = request.url!
+ let message = "Mock data does not support \(request.httpMethod ?? "GET") \(url.path)"
+ return PlexDebugMockResponse(
+ response: HTTPURLResponse(
+ url: url, statusCode: status, httpVersion: nil,
+ headerFields: ["Content-Type": "text/plain; charset=utf-8"]
+ )!,
+ data: Data(message.utf8)
+ )
+ }
+
+ private func hasProgress(_ record: PlexMockMediaCatalog.Record) -> Bool {
+ let items = record.item.hasChildren ? catalog.leaves(of: record.item.ratingKey) : [record]
+ return items.contains { ($0.item.viewOffset ?? 0) > 0 && !$0.item.isWatched }
+ }
+
+ private func playlistRecords(in section: PlexDebugMockLibrarySection) -> [PlexMockMediaCatalog.Record] {
+ section.rawType == "artist"
+ ? section.recentItems.flatMap { catalog.leaves(of: $0.item.ratingKey) }
+ : section.recentItems
+ }
+
+ private func catalogResponse(for request: URLRequest) -> PlexDebugMockResponse? {
+ guard let url = request.url else { return nil }
+ let components = url.path.split(separator: "/").map(String.init)
+ let query = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
+ let count = max(query.first { $0.name == "count" || $0.name == "limit" }?.value.flatMap(Int.init) ?? 20, 1)
+
+ if let ids = metadataIDs(for: url.path) {
+ let objects = ids.sorted().compactMap { id -> [String: Any]? in
+ if let record = catalog.record(for: id) {
+ return record.object(referenceDate: snapshotDate)
+ }
+ if let section = librarySection(forMockCollectionID: id) {
+ return collectionObject(from: section, collectionID: id)
+ }
+ return playlistObjects().first { $0["ratingKey"] as? String == id }
+ }
+ guard objects.count == ids.count else { return errorResponse(for: request, status: 404) }
+ return pagedMetadataResponse(for: request, metadata: objects)
+ }
+
+ if components.count == 4, components[0] == "library", components[1] == "metadata",
+ let record = catalog.record(for: components[2]) {
+ let records: [PlexMockMediaCatalog.Record]
+ switch components[3] {
+ case "children": records = catalog.children(of: record.item.ratingKey)
+ case "grandchildren", "allLeaves": records = catalog.leaves(of: record.item.ratingKey)
+ case "extras": records = record.extraIDs.compactMap { catalog.record(for: $0) }
+ default: return nil
+ }
+ return pagedMetadataResponse(for: request, metadata: records.map { $0.object(referenceDate: snapshotDate) })
+ }
+
+ if url.path == "/hubs/continueWatching" || url.path == "/hubs/home/continueWatching" {
+ let records = catalog.records.filter {
+ ($0.item.type == "movie" || $0.item.type == "episode") && hasProgress($0)
+ }
+ if url.path == "/hubs/home/continueWatching" {
+ return pagedMetadataResponse(for: request, metadata: records.map { $0.object(referenceDate: snapshotDate) })
+ }
+ return jsonResponse(url: url, object: ["MediaContainer": ["Hub": [hubObject(
+ id: "continueWatching", title: "Continue Watching", type: "mixed",
+ key: "/hubs/home/continueWatching", records: records, count: count
+ )]]])
+ }
+
+ if url.path == "/hubs/search" || url.path == "/hubs/search/results" {
+ let text = query.first { $0.name == "query" }?.value?.nilIfBlank ?? ""
+ let type = query.first { $0.name == "type" }?.value
+ let matches = catalog.records.filter { record in
+ let item = record.item
+ guard !text.isEmpty, !["season", "clip"].contains(item.type ?? "") else { return false }
+ return [item.title, item.parentTitle, item.grandparentTitle].compactMap { $0 }
+ .contains { $0.localizedStandardContains(text) }
+ || (item.roles + item.directors + item.writers).contains { $0.tag.localizedStandardContains(text) }
+ }
+ if url.path == "/hubs/search/results" {
+ return pagedMetadataResponse(for: request, metadata: matches.filter { $0.item.type == type }
+ .map { $0.object(referenceDate: snapshotDate) })
+ }
+ let groups = [("movie", "Movies"), ("show", "TV Shows"), ("episode", "Episodes"),
+ ("artist", "Artists"), ("album", "Albums"), ("track", "Tracks")]
+ let hubs = groups.compactMap { type, title -> [String: Any]? in
+ let items = matches.filter { $0.item.type == type }
+ guard !items.isEmpty else { return nil }
+ var path = URLComponents()
+ path.path = "/hubs/search/results"
+ path.queryItems = [URLQueryItem(name: "query", value: text), URLQueryItem(name: "type", value: type)]
+ return hubObject(id: "search.\(type)", title: title, type: type,
+ key: path.string!, records: items, count: count)
+ }
+ return jsonResponse(url: url, object: ["MediaContainer": ["Hub": hubs]])
+ }
+
+ if components.count == 4 || components.count == 5,
+ components[0] == "hubs", components[1] == "metadata", components[3] == "related",
+ let record = catalog.record(for: components[2]) {
+ let related = record.relatedIDs.compactMap { catalog.record(for: $0) }
+ let key = "/hubs/metadata/\(record.item.ratingKey)/related/items"
+ if components.count == 5 {
+ guard components[4] == "items" else { return nil }
+ return pagedMetadataResponse(for: request, metadata: related.map { $0.object(referenceDate: snapshotDate) })
+ }
+ let hubs = related.isEmpty ? [] : [hubObject(
+ id: "related.\(record.item.ratingKey)", title: "More in This Library",
+ type: related.first?.item.type ?? "mixed", key: key, records: related, count: count
+ )]
+ return jsonResponse(url: url, object: ["MediaContainer": ["Hub": hubs]])
+ }
+
+ if (components.count == 3 || components.count == 4),
+ components[0] == "library", components[1] == "people" {
+ let id = components[2]
+ let records = catalog.records.filter { record in
+ let item = record.item
+ return (item.roles + item.directors + item.writers + item.producers).contains { $0.tagKey == id }
+ }
+ guard let item = records.first?.item,
+ let person = (item.roles + item.directors + item.writers + item.producers).first(where: { $0.tagKey == id }) else {
+ return errorResponse(for: request, status: 404)
+ }
+ if components.count == 4 {
+ guard components[3] == "media" else { return nil }
+ return pagedMetadataResponse(for: request, metadata: records.map { $0.object(referenceDate: snapshotDate) })
+ }
+ return jsonResponse(url: url, object: ["MediaContainer": ["Directory": [compactObject([
+ "id": person.id, "tag": person.tag, "tagKey": person.tagKey, "thumb": person.thumb
+ ])]]])
+ }
+ return nil
+ }
+
+ private func hubObject(
+ id: String, title: String, type: String, key: String,
+ records: [PlexMockMediaCatalog.Record], count: Int
+ ) -> [String: Any] {
+ let items = Array(records.prefix(count))
+ return ["hubIdentifier": id, "key": key, "title": title, "type": type,
+ "size": items.count, "totalSize": records.count, "more": records.count > items.count,
+ "Metadata": items.map { $0.object(referenceDate: snapshotDate) }]
+ }
+
+ private func remoteResponse(for request: URLRequest) -> PlexDebugMockResponse? {
+ guard let url = request.url else {
+ return nil
+ }
+
+ if let response = remoteAuthenticationResponse(for: url) {
+ return response
+ }
+
+ if url.path == "/api/v2/user" {
+ return jsonResponse(
+ url: url,
+ object: compactObject([
+ "id": authenticatedUser.id,
+ "username": authenticatedUser.username,
+ "title": authenticatedUser.title,
+ "email": authenticatedUser.email,
+ "thumb": authenticatedUser.thumb,
+ "friendlyName": authenticatedUser.friendlyName,
+ ])
+ )
+ }
+
+ if url.path == "/api/v2/resources" {
+ return serverResourcesResponse(for: url)
+ }
+
+ if url.path == "/api/v2/devices" {
+ return serverDevicesResponse(for: url)
+ }
+
+ if url.path == "/api/v2/geoip" {
+ return geoIPResponse(for: url)
+ }
+
+ return nil
+ }
+
+ private func remoteAuthenticationResponse(for url: URL) -> PlexDebugMockResponse? {
+ if url.path == "/api/v2/pins" {
+ return jsonResponse(
+ url: url,
+ object: [
+ "id": 4242,
+ "code": "PLEXBAR-MOCK",
+ "authToken": NSNull()
+ ]
+ )
+ }
+
+ if url.path.hasPrefix("/api/v2/pins/") {
+ return jsonResponse(
+ url: url,
+ object: [
+ "id": 4242,
+ "code": "PLEXBAR-MOCK",
+ "authToken": userToken
+ ]
+ )
+ }
+
+ if url.path == "/api/v2/auth/jwk" {
+ return jsonResponse(url: url, object: [:])
+ }
+
+ if url.path == "/api/v2/auth/nonce" {
+ return jsonResponse(url: url, object: ["nonce": "plexbar-mock-nonce"])
+ }
+
+ if url.path == "/api/v2/auth/token" {
+ return jsonResponse(url: url, object: ["auth_token": userToken])
+ }
+
+ return nil
+ }
+
+ private func geoIPResponse(for url: URL) -> PlexDebugMockResponse? {
+ guard let ipAddress = URLComponents(url: url, resolvingAgainstBaseURL: false)?
+ .queryItems?
+ .first(where: { $0.name == "ip_address" })?
+ .value else {
+ return nil
+ }
+
+ let location = resolvedLocationsByIPAddress[ipAddress]
+
+ let xml: String
+ if let location {
+ let parts = location.split(separator: ",", maxSplits: 1).map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ if parts.count == 2, parts[1].count <= 3 {
+ xml = ""
+ } else if parts.count == 2 {
+ xml = ""
+ } else {
+ xml = ""
+ }
+ } else {
+ xml = ""
+ }
+
+ let data = Data(xml.utf8)
+ let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/xml"])!
+ return PlexDebugMockResponse(response: response, data: data)
+ }
+
+ private func imageResponse(for url: URL) -> PlexDebugMockResponse? {
+ guard let artwork = artwork.first(where: { $0.url.path == url.path }) else {
+ return nil
+ }
+
+ let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": artwork.contentType])!
+ return PlexDebugMockResponse(response: response, data: artwork.data)
+ }
+
+ private func transcodedImageResponse(for url: URL) -> PlexDebugMockResponse? {
+ guard let sourcePath = URLComponents(url: url, resolvingAgainstBaseURL: false)?
+ .queryItems?
+ .first(where: { $0.name == "url" })?
+ .value,
+ sourcePath.hasPrefix("/mock/") else {
+ return nil
+ }
+
+ guard let sourceURL = PlexURLBuilder.mediaURL(serverURL: activeConnection.url, path: sourcePath) else {
+ return nil
+ }
+
+ return imageResponse(for: sourceURL)
+ }
+
+ private func serverResourcesResponse(for url: URL) -> PlexDebugMockResponse? {
+ jsonResponse(
+ url: url,
+ object: [
+ compactObject([
+ "name": server.name,
+ "clientIdentifier": server.id,
+ "accessToken": server.accessToken,
+ "provides": "server",
+ "productVersion": server.productVersion,
+ "connections": server.connections.map { connection in
+ [
+ "uri": connection.uri.absoluteString,
+ "local": connection.local,
+ "relay": connection.relay
+ ]
+ }
+ ])
+ ]
+ )
+ }
+
+ private func serverDevicesResponse(for url: URL) -> PlexDebugMockResponse? {
+ jsonResponse(
+ url: url,
+ object: [
+ compactObject([
+ "name": server.name,
+ "clientIdentifier": server.id,
+ "token": server.accessToken,
+ "provides": "server",
+ "connections": server.connections.map { connection in
+ ["uri": connection.uri.absoluteString]
+ }
+ ])
+ ]
+ )
+ }
+
+ private func libraryID(for path: String) -> String? {
+ let components = path.split(separator: "/")
+ guard components.count == 4,
+ components[0] == "library",
+ components[1] == "sections",
+ components[3] == "all" else {
+ return nil
+ }
+
+ return String(components[2])
+ }
+
+ private func libraryDetailsID(for path: String) -> String? {
+ let components = path.split(separator: "/")
+ guard components.count == 3,
+ components[0] == "library",
+ components[1] == "sections" else {
+ return nil
+ }
+ return String(components[2])
+ }
+
+ private func collectionsLibraryID(for path: String) -> String? {
+ let components = path.split(separator: "/")
+ guard components.count == 4,
+ components[0] == "library",
+ components[1] == "sections",
+ components[3] == "collections" else {
+ return nil
+ }
+ return String(components[2])
+ }
+
+ private func libraryDescriptorID(for path: String, descriptor: String) -> String? {
+ let components = path.split(separator: "/")
+ guard components.count == 4,
+ components[0] == "library",
+ components[1] == "sections",
+ components[3] == Substring(descriptor) else {
+ return nil
+ }
+ return String(components[2])
+ }
+
+ private func collectionItemsID(for path: String) -> String? {
+ let components = path.split(separator: "/")
+ guard components.count == 4,
+ components[0] == "library",
+ components[1] == "collections",
+ components[3] == "items" else {
+ return nil
+ }
+ return String(components[2])
+ }
+
+ private func playlistItemsID(for path: String) -> String? {
+ let components = path.split(separator: "/")
+ guard components.count == 3,
+ components[0] == "playlists",
+ components[2] == "items" else {
+ return nil
+ }
+ return String(components[1])
+ }
+
+ private func metadataIDs(for path: String) -> Set? {
+ let components = path.split(separator: "/")
+ guard components.count == 3,
+ components[0] == "library",
+ components[1] == "metadata" else {
+ return nil
+ }
+
+ return Set(components[2].split(separator: ",").map(String.init))
+ }
+
+ private func streamID(forLevelsPath path: String) -> Int? {
+ let components = path.split(separator: "/")
+ guard components.count == 4,
+ components[0] == "library",
+ components[1] == "streams",
+ components[3] == "levels" else {
+ return nil
+ }
+
+ return Int(components[2])
+ }
+
+ private func jsonResponse(url: URL, object: Any, headers: [String: String] = [:]) -> PlexDebugMockResponse? {
+ guard let data = try? JSONSerialization.data(withJSONObject: object) else {
+ return nil
+ }
+
+ let response = HTTPURLResponse(
+ url: url,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: headers.merging(["Content-Type": "application/json"]) { current, _ in current }
+ )!
+ return PlexDebugMockResponse(response: response, data: data)
+ }
+
+ private func pagedMetadataResponse(
+ for request: URLRequest,
+ metadata: [[String: Any]]
+ ) -> PlexDebugMockResponse? {
+ guard let url = request.url else {
+ return nil
+ }
+ let start = max(Int(request.value(forHTTPHeaderField: "X-Plex-Container-Start") ?? "") ?? 0, 0)
+ let size = max(Int(request.value(forHTTPHeaderField: "X-Plex-Container-Size") ?? "") ?? metadata.count, 0)
+ let page = Array(metadata.dropFirst(start).prefix(size))
+ return jsonResponse(
+ url: url,
+ object: [
+ "MediaContainer": [
+ "size": page.count,
+ "totalSize": metadata.count,
+ "offset": start,
+ "Metadata": page
+ ]
+ ],
+ headers: ["X-Plex-Container-Total-Size": String(metadata.count)]
+ )
+ }
+
+ private func xmlResponse(url: URL, body: String) -> PlexDebugMockResponse {
+ let data = Data(body.utf8)
+ let response = HTTPURLResponse(
+ url: url,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: ["Content-Type": "application/xml"]
+ )!
+ return PlexDebugMockResponse(response: response, data: data)
+ }
+
+ private func sessionObject(from session: PlexSession) -> [String: Any] {
+ var object = compactObject([
+ "sessionKey": session.sessionKey,
+ "ratingKey": session.ratingKey,
+ "key": session.key,
+ "type": session.type,
+ "title": session.title,
+ "grandparentTitle": session.grandparentTitle,
+ "parentTitle": session.parentTitle,
+ "parentIndex": session.parentIndex,
+ "index": session.index,
+ "thumb": session.thumb,
+ "parentThumb": session.parentThumb,
+ "grandparentThumb": session.grandparentThumb,
+ "art": session.art,
+ "duration": session.duration,
+ "viewOffset": session.viewOffset,
+ "year": session.year,
+ "User": compactObject([
+ "id": session.user?.id,
+ "thumb": session.user?.thumb,
+ "title": session.user?.title,
+ ]),
+ "Player": compactObject([
+ "address": session.player.address,
+ "machineIdentifier": session.player.machineIdentifier,
+ "platform": session.player.platform,
+ "product": session.player.product,
+ "remotePublicAddress": session.player.remotePublicAddress,
+ "state": session.player.state,
+ "title": session.player.title,
+ "local": session.player.local,
+ "relayed": session.player.relayed,
+ "secure": session.player.secure,
+ ]),
+ "Session": compactObject([
+ "id": session.session?.id,
+ "bandwidth": session.session?.bandwidth,
+ "location": session.session?.location,
+ ]),
+ ])
+
+ if let transcodeSession = session.transcodeSession {
+ object["TranscodeSession"] = compactObject([
+ "key": transcodeSession.key,
+ "videoDecision": transcodeSession.videoDecision,
+ "audioDecision": transcodeSession.audioDecision,
+ "sourceVideoCodec": transcodeSession.sourceVideoCodec,
+ "sourceAudioCodec": transcodeSession.sourceAudioCodec,
+ "videoCodec": transcodeSession.videoCodec,
+ "audioCodec": transcodeSession.audioCodec,
+ "transcodeHwDecoding": transcodeSession.transcodeHwDecoding,
+ "transcodeHwEncoding": transcodeSession.transcodeHwEncoding,
+ ])
+ }
+
+ if let media = session.media {
+ object["Media"] = media.map { media in
+ compactObject([
+ "selected": media.selected,
+ "Part": (media.part ?? []).map { part in
+ var partObject = compactObject(["decision": part.decision, "selected": part.selected])
+
+ if let stream = part.stream {
+ partObject["Stream"] = stream.map { stream in
+ compactObject([
+ "id": stream.id,
+ "streamType": stream.streamType,
+ "codec": stream.codec,
+ "selected": stream.selected,
+ "decision": stream.decision,
+ "displayTitle": stream.displayTitle,
+ "language": stream.language,
+ "bitrate": stream.bitrate,
+ ])
+ }
+ }
+
+ return partObject
+ }
+ ])
+ }
+ }
+
+ return object
+ }
+
+ private func historyItemObject(from item: PlexHistoryItem) -> [String: Any] {
+ compactObject([
+ "historyKey": item.historyKey,
+ "key": item.key,
+ "ratingKey": item.ratingKey,
+ "title": item.title,
+ "type": item.type,
+ "thumb": item.thumb,
+ "parentThumb": item.parentThumb,
+ "grandparentThumb": item.grandparentThumb,
+ "art": item.art,
+ "grandparentTitle": item.grandparentTitle,
+ "parentTitle": item.parentTitle,
+ "parentIndex": item.parentIndex,
+ "index": item.index,
+ "originallyAvailableAt": item.originallyAvailableAt,
+ "viewedAt": item.viewedAt.map { Int($0.timeIntervalSince1970) },
+ "accountID": item.accountID,
+ "deviceID": item.deviceID,
+ ])
+ }
+
+ private func accountObject(from account: PlexAccount) -> [String: Any] {
+ compactObject([
+ "id": account.id,
+ "name": account.name,
+ "thumb": account.thumb,
+ ])
+ }
+
+ private func libraryDirectoryObject(from section: PlexDebugMockLibrarySection) -> [String: Any] {
+ let library = section.library
+ return compactObject([
+ "key": library.id,
+ "title": library.title,
+ "type": section.rawType,
+ "composite": library.compositePath,
+ "art": library.artPath,
+ "thumb": library.thumbPath,
+ "updatedAt": library.updatedAt.map { Int($0.timeIntervalSince1970) },
+ "scannedAt": library.scannedAt.map { Int($0.timeIntervalSince1970) },
+ "contentChangedAt": library.contentChangedAt.map { Int($0.timeIntervalSince1970) },
+ "content": true,
+ "directory": true,
+ ])
+ }
+
+ private func libraryProviderDirectoryObject(
+ from section: PlexDebugMockLibrarySection
+ ) -> [String: Any]? {
+ guard let metadataTypeID = section.library.type.metadataTypeID else {
+ return nil
+ }
+ return [
+ "id": section.library.id,
+ "key": "/library/sections/\(section.library.id)",
+ "type": section.rawType,
+ "title": section.library.title,
+ "Pivot": [[
+ "id": "library",
+ "key": "/library/sections/\(section.library.id)/all?type=\(metadataTypeID)",
+ "type": "list",
+ "title": "Library",
+ ]],
+ ]
+ }
+
+ private func libraryFiltersObject(
+ from section: PlexDebugMockLibrarySection
+ ) -> [String: Any] {
+ [
+ "MediaContainer": [
+ "Directory": [
+ [
+ "filter": "unwatched",
+ "filterType": "boolean",
+ "key": "/library/sections/\(section.library.id)/unwatched",
+ "title": "Unwatched",
+ ],
+ [
+ "filter": "inProgress",
+ "filterType": "boolean",
+ "key": "/library/sections/\(section.library.id)/inProgress",
+ "title": "In Progress",
+ ],
+ ]
+ ]
+ ]
+ }
+
+ private func librarySortsObject(
+ from section: PlexDebugMockLibrarySection
+ ) -> [String: Any] {
+ [
+ "MediaContainer": [
+ "Directory": [
+ [
+ "default": "asc",
+ "defaultDirection": "asc",
+ "descKey": "titleSort:desc",
+ "key": "titleSort",
+ "title": "Name",
+ ],
+ [
+ "defaultDirection": "desc",
+ "descKey": "addedAt:desc",
+ "key": "addedAt",
+ "title": "Date Added",
+ ],
+ ]
+ ]
+ ]
+ }
+
+ private func libraryBrowseDefinitionObject(
+ from section: PlexDebugMockLibrarySection
+ ) -> [String: Any] {
+ guard let metadataTypeID = section.library.type.metadataTypeID else {
+ return ["MediaContainer": ["Directory": [], "Type": []]]
+ }
+ let browseTypes: [(id: Int, type: String, title: String)] = section.rawType == "artist"
+ ? [(8, "artist", "Artists"), (9, "album", "Albums"), (10, "track", "Tracks")]
+ : [(metadataTypeID, section.rawType, section.library.title)]
+ let filters = (libraryFiltersObject(from: section)["MediaContainer"] as? [String: Any])?["Directory"]
+ as? [[String: Any]] ?? []
+ let sorts = (librarySortsObject(from: section)["MediaContainer"] as? [String: Any])?["Directory"]
+ as? [[String: Any]] ?? []
+ return [
+ "MediaContainer": [
+ "Directory": [["key": "all", "title": "All \(browseTypes[0].title)"]],
+ "Type": browseTypes.map { type in
+ [
+ "key": "/library/sections/\(section.library.id)/all?type=\(type.id)",
+ "type": type.type,
+ "title": type.title,
+ "Filter": section.rawType == "artist" ? [] : filters,
+ "Sort": sorts,
+ ] as [String: Any]
+ },
+ ]
+ ]
+ }
+
+ private func sortedLibraryItems(
+ _ items: [PlexMockMediaCatalog.Record],
+ sort: String?
+ ) -> [PlexMockMediaCatalog.Record] {
+ switch sort {
+ case "titleSort:desc":
+ items.sorted { $0.item.title.localizedStandardCompare($1.item.title) == .orderedDescending }
+ case "addedAt":
+ items.sorted { $0.addedAtSecondsAgo > $1.addedAtSecondsAgo }
+ case "addedAt:desc":
+ items.sorted { $0.addedAtSecondsAgo < $1.addedAtSecondsAgo }
+ default:
+ items.sorted { $0.item.title.localizedStandardCompare($1.item.title) == .orderedAscending }
+ }
+ }
+
+ private func browseItemObject(
+ from item: PlexMockMediaCatalog.Record,
+ in section: PlexDebugMockLibrarySection
+ ) -> [String: Any] {
+ item.object(referenceDate: snapshotDate)
+ }
+
+ private func promotedHubObjects(itemLimit: Int) -> [[String: Any]] {
+ librarySections.compactMap { section in
+ guard let metadataTypeID = section.library.type.metadataTypeID,
+ !section.recentItems.isEmpty else {
+ return nil
+ }
+
+ let isAudio = section.rawType == "artist"
+ let recentItems = isAudio
+ ? sortedLibraryItems(catalog.records.filter {
+ $0.item.librarySectionID == section.library.id && $0.item.type == "album"
+ }, sort: "addedAt:desc")
+ : section.recentItems
+ let items = Array(recentItems.prefix(itemLimit))
+ let metadata = items.map { browseItemObject(from: $0, in: section) }
+ let hubKey = items
+ .map { "/library/metadata/\($0.item.ratingKey)" }
+ .joined(separator: ",")
+ return [
+ "hubIdentifier": isAudio ? "music.recent.added.\(section.library.id)" : "home.\(section.library.id).recent",
+ "hubKey": hubKey,
+ "key": isAudio
+ ? "/library/sections/\(section.library.id)/all?type=9&sort=addedAt:desc"
+ : "/hubs/home/recentlyAdded?type=\(metadataTypeID)",
+ "title": "Recently Added \(section.library.title)",
+ "type": isAudio ? "album" : section.rawType,
+ "size": metadata.count,
+ "totalSize": recentItems.count,
+ "more": recentItems.count > metadata.count,
+ "style": "shelf",
+ "promoted": true,
+ "Metadata": metadata
+ ]
+ }
+ }
+
+ private func homeHubLibrarySection(for url: URL) -> PlexDebugMockLibrarySection? {
+ let metadataTypeID = URLComponents(url: url, resolvingAgainstBaseURL: false)?
+ .queryItems?
+ .first(where: { $0.name == "type" })?
+ .value
+ .flatMap(Int.init)
+ return librarySections.first { $0.library.type.metadataTypeID == metadataTypeID }
+ }
+
+ private func mockCollectionID(for libraryID: String) -> String? {
+ librarySections.firstIndex { $0.library.id == libraryID }
+ .map { String(9_101 + $0) }
+ }
+
+ private func librarySection(
+ forMockCollectionID collectionID: String
+ ) -> PlexDebugMockLibrarySection? {
+ guard let numericID = Int(collectionID) else {
+ return nil
+ }
+ let index = numericID - 9_101
+ guard librarySections.indices.contains(index) else {
+ return nil
+ }
+ return librarySections[index]
+ }
+
+ private func collectionObject(
+ from section: PlexDebugMockLibrarySection,
+ collectionID: String
+ ) -> [String: Any] {
+ compactObject([
+ "ratingKey": collectionID,
+ "key": "/library/collections/\(collectionID)/items",
+ "type": "collection",
+ "title": "\(section.library.title) Collection",
+ "composite": section.recentItems.first?.item.thumb,
+ "leafCount": section.recentItems.count
+ ])
+ }
+
+ private func playlistObjects() -> [[String: Any]] {
+ let definitions = [
+ (id: "9201", type: "video", sectionType: "movie", title: "Video Playlist"),
+ (id: "9202", type: "audio", sectionType: "artist", title: "Audio Playlist")
+ ]
+ return definitions.compactMap { definition in
+ guard let section = librarySections.first(where: { $0.rawType == definition.sectionType }) else {
+ return nil
+ }
+ return compactObject([
+ "ratingKey": definition.id,
+ "key": "/playlists/\(definition.id)/items",
+ "type": "playlist",
+ "title": definition.title,
+ "composite": section.recentItems.first?.item.thumb,
+ "leafCount": playlistRecords(in: section).count,
+ "readOnly": true,
+ "playlistType": definition.type,
+ "smart": false
+ ])
+ }
+ }
+
+ private func librarySection(
+ forMockPlaylistID playlistID: String
+ ) -> PlexDebugMockLibrarySection? {
+ let rawType: String
+ switch playlistID {
+ case "9201":
+ rawType = "movie"
+ case "9202":
+ rawType = "artist"
+ default:
+ return nil
+ }
+ return librarySections.first { $0.rawType == rawType }
+ }
+
+ private func compactObject(_ values: [String: Any?]) -> [String: Any] {
+ values.compactMapValues { $0 }
+ }
+
+ private func xmlEscaped(_ value: S) -> String {
+ String(value)
+ .replacingOccurrences(of: "&", with: "&")
+ .replacingOccurrences(of: "\"", with: """)
+ .replacingOccurrences(of: "<", with: "<")
+ .replacingOccurrences(of: ">", with: ">")
+ }
+
+ private static func materializeSession(
+ _ session: PlexMockServerPayload.ActiveSession,
+ usersByID: [Int: PlexMockServerPayload.User],
+ catalog: PlexMockMediaCatalog
+ ) -> PlexSession {
+ guard let profile = usersByID[session.userID],
+ let device = profile.devices.first(where: { $0.id == session.deviceID }),
+ let media = catalog.record(for: session.mediaID)?.item else {
+ preconditionFailure("Missing mock session user, device, or media")
+ }
+ let mediaParts: [PlexMedia]?
+ if session.mediaDecision != nil || session.audioStream != nil {
+ let stream = session.audioStream.map {
+ PlexStream(
+ id: $0.id,
+ streamType: $0.streamType,
+ codec: $0.codec,
+ selected: $0.selected
+ )
+ }
+ mediaParts = [PlexMedia(part: [PlexPart(
+ decision: session.mediaDecision,
+ stream: (session.mediaStreams ?? []) + (stream.map { [$0] } ?? [])
+ )])]
+ } else {
+ mediaParts = nil
+ }
+
+ return PlexSession(
+ sessionKey: session.sessionKey,
+ ratingKey: media.ratingKey,
+ key: "/library/metadata/\(media.ratingKey)",
+ type: media.type,
+ subtype: nil,
+ live: false,
+ title: media.title,
+ grandparentTitle: media.grandparentTitle,
+ parentTitle: media.parentTitle,
+ parentIndex: media.parentIndex,
+ index: media.index,
+ thumb: media.thumb,
+ parentThumb: media.parentThumb,
+ grandparentThumb: media.grandparentThumb,
+ art: media.art,
+ duration: media.duration,
+ viewOffset: session.viewOffset,
+ year: media.year,
+ user: profile.materializeUser(),
+ player: device.materializePlayer(state: session.state),
+ session: session.session?.materialize(location: device.connection.sessionLocation),
+ transcodeSession: session.transcodeSession,
+ media: mediaParts
+ )
+ }
+
+ private static func materializeHistoryItem(
+ _ event: PlexMockServerPayload.HistoryEvent,
+ referenceDate: Date,
+ catalog: PlexMockMediaCatalog
+ ) -> PlexHistoryItem {
+ guard let media = catalog.record(for: event.mediaID)?.item else {
+ preconditionFailure("Missing mock history media \(event.mediaID)")
+ }
+
+ return PlexHistoryItem(
+ historyKey: event.historyKey,
+ key: "/library/metadata/\(media.ratingKey)",
+ ratingKey: media.ratingKey,
+ title: media.title,
+ type: media.type,
+ thumb: media.thumb,
+ parentThumb: media.parentThumb,
+ grandparentThumb: media.grandparentThumb,
+ art: media.art,
+ grandparentTitle: media.grandparentTitle,
+ parentTitle: media.parentTitle,
+ parentIndex: media.parentIndex,
+ index: media.index,
+ originallyAvailableAt: media.originallyAvailableAt,
+ viewedAt: referenceDate.addingTimeInterval(-TimeInterval(event.viewedAtSecondsAgo)),
+ accountID: event.userID,
+ deviceID: event.deviceID
+ )
+ }
+
+ private static func materializeLibrarySection(
+ _ library: PlexMockServerPayload.Library,
+ referenceDate: Date,
+ catalog: PlexMockMediaCatalog
+ ) -> PlexDebugMockLibrarySection {
+ let recentItems = library.entries.map { catalog.record(for: $0.mediaID)! }
+ .sorted { $0.addedAtSecondsAgo < $1.addedAtSecondsAgo }
+ let latest = recentItems.first
+ let secondaryType: String? = switch library.type {
+ case "show": "season"
+ case "artist": "album"
+ default: nil
+ }
+ let secondaryCount = secondaryType.map { type in
+ catalog.records.filter { $0.item.librarySectionID == library.id && $0.item.type == type }.count
+ }
+ return PlexDebugMockLibrarySection(
+ library: PlexLibrary(
+ id: library.id,
+ title: library.title,
+ type: PlexLibraryType(rawValue: library.type),
+ compositePath: latest?.item.thumb,
+ artPath: latest?.item.art,
+ thumbPath: latest?.item.thumb,
+ itemCount: recentItems.count,
+ secondaryCount: secondaryCount,
+ secondaryCountLabel: secondaryType.map { $0 == "season" ? "seasons" : "albums" },
+ updatedAt: library.updatedAtSecondsAgo.map { referenceDate.addingTimeInterval(-TimeInterval($0)) },
+ scannedAt: library.scannedAtSecondsAgo.map { referenceDate.addingTimeInterval(-TimeInterval($0)) },
+ contentChangedAt: library.contentChangedAtSecondsAgo.map { referenceDate.addingTimeInterval(-TimeInterval($0)) },
+ latestAddedAt: latest.map { referenceDate.addingTimeInterval(-TimeInterval($0.addedAtSecondsAgo)) },
+ latestItemTitle: latest?.item.title
+ ),
+ rawType: library.type,
+ recentItems: recentItems
+ )
+ }
+}
+
+private struct DebugMockArtwork {
+ let url: URL
+ let data: Data
+ let contentType: String
+
+ static func load(serverURL: URL, mockPath: String, resource: String) -> DebugMockArtwork {
+ let sourceURL = PlexMockServerResourceLocator.url(for: resource)
+ let data = try! Data(contentsOf: sourceURL)
+ return DebugMockArtwork(
+ url: PlexURLBuilder.mediaURL(serverURL: serverURL, path: mockPath)!,
+ data: data,
+ contentType: sourceURL.pathExtension == "png" ? "image/png" : "image/jpeg"
+ )
+ }
+}
+
+private struct PlexDebugMockLibrarySection {
+ let library: PlexLibrary
+ let rawType: String
+ let recentItems: [PlexMockMediaCatalog.Record]
+}
+
+private final class PlexDebugMockState: @unchecked Sendable {
+ private let lock = NSLock()
+ private var terminatedSessionIDs: Set = []
+
+ func terminateSession(withID sessionID: String) {
+ guard let sessionID = sessionID.nilIfBlank else {
+ return
+ }
+
+ _ = lock.withLock {
+ terminatedSessionIDs.insert(sessionID)
+ }
+ }
+
+ func isTerminated(_ session: PlexSession) -> Bool {
+ guard let serverSessionID = session.serverSessionID else {
+ return false
+ }
+
+ return lock.withLock {
+ terminatedSessionIDs.contains(serverSessionID)
+ }
+ }
+}
+
+private final class PlexDebugMockStateRegistry: @unchecked Sendable {
+ static let shared = PlexDebugMockStateRegistry()
+ static let headerName = "X-PlexBar-Mock-State-ID"
+
+ private let lock = NSLock()
+ private var states: [String: PlexDebugMockState] = [:]
+
+ private init() {}
+
+ func register(_ state: PlexDebugMockState) -> String {
+ let id = UUID().uuidString
+
+ lock.withLock {
+ states[id] = state
+ }
+
+ return id
+ }
+
+ func state(for request: URLRequest) -> PlexDebugMockState? {
+ guard let id = request.value(forHTTPHeaderField: Self.headerName) else {
+ return nil
+ }
+
+ return lock.withLock {
+ states[id]
+ }
+ }
+}
+
+private final class PlexDebugMockURLProtocol: URLProtocol, @unchecked Sendable {
+ override class func canInit(with request: URLRequest) -> Bool {
+ true
+ }
+
+ override class func canonicalRequest(for request: URLRequest) -> URLRequest {
+ request
+ }
+
+ override func startLoading() {
+ guard let state = PlexDebugMockStateRegistry.shared.state(for: request) else {
+ client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
+ return
+ }
+ let response = debugFixture.response(for: request, state: state)
+ ?? debugFixture.errorResponse(for: request, status: 404)
+ client?.urlProtocol(self, didReceive: response.response, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: response.data)
+ client?.urlProtocolDidFinishLoading(self)
+ }
+
+ override func stopLoading() {}
+
+}
+
+private struct PlexDebugMockResponse {
+ let response: HTTPURLResponse
+ let data: Data
+}
+
+#endif
diff --git a/PlexBar/Support/PlexDescriptionLineBreak.swift b/PlexBar/Support/PlexDescriptionLineBreak.swift
new file mode 100644
index 0000000..ecd4651
--- /dev/null
+++ b/PlexBar/Support/PlexDescriptionLineBreak.swift
@@ -0,0 +1,23 @@
+import AppKit
+import CoreText
+
+/// Finds the first natural line break without reserving space for the button.
+/// This is a read-only text calculation; it has no views or layout callbacks.
+struct PlexDescriptionLineBreak {
+ let firstLine: String
+ let remainingText: String
+
+ init?(summary: String, width: CGFloat, font: NSFont) {
+ guard width > 0, width.isFinite, !summary.isEmpty else { return nil }
+ let text = summary as NSString
+ let attributed = NSAttributedString(string: summary, attributes: [.font: font])
+ let typesetter = CTTypesetterCreateWithAttributedString(attributed)
+ let firstLength = CTTypesetterSuggestLineBreak(typesetter, 0, width)
+ guard firstLength > 0, firstLength < text.length else { return nil }
+ let secondLength = CTTypesetterSuggestLineBreak(typesetter, firstLength, width)
+ guard firstLength + secondLength < text.length else { return nil }
+
+ firstLine = text.substring(to: firstLength).trimmingCharacters(in: .newlines)
+ remainingText = text.substring(from: firstLength)
+ }
+}
diff --git a/PlexBar/Support/PlexEpisodeSpoilerPresentation.swift b/PlexBar/Support/PlexEpisodeSpoilerPresentation.swift
new file mode 100644
index 0000000..2819473
--- /dev/null
+++ b/PlexBar/Support/PlexEpisodeSpoilerPresentation.swift
@@ -0,0 +1,45 @@
+import PlexModels
+import Foundation
+
+enum PlexEpisodeSpoilerPolicy: String, CaseIterable, Identifiable, Sendable {
+ case off
+ case unwatchedEpisodes
+ case allEpisodes
+
+ var id: Self { self }
+
+ var label: String {
+ switch self {
+ case .off: "Off"
+ case .unwatchedEpisodes: "Unwatched Episodes"
+ case .allEpisodes: "All Episodes"
+ }
+ }
+
+ func hidesSpoilers(for item: PlexMediaItem) -> Bool {
+ guard item.type?.lowercased() == "episode" else {
+ return false
+ }
+
+ switch self {
+ case .off:
+ return false
+ case .unwatchedEpisodes:
+ return (item.viewCount ?? 0) == 0
+ case .allEpisodes:
+ return true
+ }
+ }
+}
+
+struct PlexEpisodeSpoilerPresentation: Equatable, Sendable {
+ let isProtected: Bool
+ let summary: String?
+ let thumbnailPath: String?
+
+ init(item: PlexMediaItem, policy: PlexEpisodeSpoilerPolicy) {
+ isProtected = policy.hidesSpoilers(for: item)
+ summary = isProtected ? nil : item.summary?.nilIfBlank
+ thumbnailPath = isProtected ? nil : item.preferredArtworkPath
+ }
+}
diff --git a/PlexBar/Support/PlexExternalRatingsPresentation.swift b/PlexBar/Support/PlexExternalRatingsPresentation.swift
new file mode 100644
index 0000000..bd0f98c
--- /dev/null
+++ b/PlexBar/Support/PlexExternalRatingsPresentation.swift
@@ -0,0 +1,117 @@
+import PlexModels
+import Foundation
+
+struct PlexExternalRatingPresentation: Equatable, Identifiable, Sendable {
+ enum Source: String, Sendable {
+ case imdb
+ case rottenTomatoes
+ }
+
+ let source: Source
+ let displayValue: String
+ let accessibilityLabel: String
+ let isFresh: Bool?
+ let destinationURL: URL?
+
+ var id: Source { source }
+}
+
+struct PlexExternalRatingsPresentation: Equatable, Sendable {
+ let ratings: [PlexExternalRatingPresentation]
+
+ init(item: PlexMediaItem, locale: Locale = .autoupdatingCurrent) {
+ ratings = [
+ Self.imdbRating(in: item.ratings, guids: item.guids, locale: locale),
+ Self.tomatometerRating(in: item.ratings, locale: locale),
+ ].compactMap { $0 }
+ }
+
+ private static func imdbRating(
+ in ratings: [PlexMediaRating],
+ guids: [PlexMediaGUID],
+ locale: Locale
+ ) -> PlexExternalRatingPresentation? {
+ guard let rating = ratings.first(where: {
+ provider(from: $0.image) == "imdb"
+ }), let value = validValue(rating.value) else {
+ return nil
+ }
+
+ let displayValue = value.formatted(
+ .number.locale(locale).precision(.fractionLength(1))
+ )
+ return .init(
+ source: .imdb,
+ displayValue: displayValue,
+ accessibilityLabel: "IMDb rating, \(displayValue) out of 10",
+ isFresh: nil,
+ destinationURL: imdbDestinationURL(in: guids)
+ )
+ }
+
+ private static func tomatometerRating(
+ in ratings: [PlexMediaRating],
+ locale: Locale
+ ) -> PlexExternalRatingPresentation? {
+ guard let rating = ratings.first(where: {
+ provider(from: $0.image) == "rottentomatoes"
+ && $0.type?.caseInsensitiveCompare("critic") == .orderedSame
+ }), let value = validValue(rating.value) else {
+ return nil
+ }
+
+ let displayValue = (value / 10).formatted(
+ .percent.locale(locale).precision(.fractionLength(0))
+ )
+ return .init(
+ source: .rottenTomatoes,
+ displayValue: displayValue,
+ accessibilityLabel: "Rotten Tomatoes Tomatometer, \(displayValue)",
+ isFresh: value >= 6,
+ destinationURL: nil
+ )
+ }
+
+ private static func imdbDestinationURL(in guids: [PlexMediaGUID]) -> URL? {
+ let identifier = guids.lazy
+ .compactMap { imdbTitleIdentifier(from: $0.id) }
+ .first
+ guard let identifier else {
+ return nil
+ }
+
+ var components = URLComponents()
+ components.scheme = "https"
+ components.host = "www.imdb.com"
+ components.path = "/title/\(identifier)/"
+ return components.url
+ }
+
+ private static func imdbTitleIdentifier(from guid: String) -> String? {
+ guard let components = URLComponents(string: guid),
+ components.scheme?.caseInsensitiveCompare("imdb") == .orderedSame,
+ let identifier = components.host,
+ identifier.hasPrefix("tt"),
+ identifier.count >= 9,
+ identifier.dropFirst(2).allSatisfy(\.isNumber) else {
+ return nil
+ }
+
+ return identifier
+ }
+
+ private static func validValue(_ value: Double?) -> Double? {
+ guard let value, value.isFinite, (0...10).contains(value) else {
+ return nil
+ }
+ return value
+ }
+
+ private static func provider(from image: String?) -> String? {
+ guard let image = image?.nilIfBlank,
+ let provider = image.split(separator: ":", maxSplits: 1).first else {
+ return nil
+ }
+ return provider.lowercased()
+ }
+}
diff --git a/PlexBar/Support/PlexImageDecoder.swift b/PlexBar/Support/PlexImageDecoder.swift
new file mode 100644
index 0000000..f6a88d5
--- /dev/null
+++ b/PlexBar/Support/PlexImageDecoder.swift
@@ -0,0 +1,38 @@
+import CoreGraphics
+import Foundation
+import ImageIO
+
+enum PlexImageDecoder {
+ @concurrent
+ static func decodeCGImage(
+ from data: Data,
+ maximumPixelSize: Int? = nil
+ ) async -> PlexCGImageBox? {
+ guard let source = CGImageSourceCreateWithData(data as CFData, nil) else {
+ return nil
+ }
+
+ if let maximumPixelSize, maximumPixelSize > 0 {
+ let options: [CFString: Any] = [
+ kCGImageSourceCreateThumbnailFromImageAlways: true,
+ kCGImageSourceCreateThumbnailWithTransform: true,
+ kCGImageSourceThumbnailMaxPixelSize: maximumPixelSize,
+ kCGImageSourceShouldCache: false,
+ kCGImageSourceShouldCacheImmediately: true,
+ ]
+ return CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)
+ .map(PlexCGImageBox.init)
+ }
+
+ return CGImageSourceCreateImageAtIndex(source, 0, nil)
+ .map(PlexCGImageBox.init)
+ }
+}
+
+final class PlexCGImageBox: @unchecked Sendable {
+ let image: CGImage
+
+ init(_ image: CGImage) {
+ self.image = image
+ }
+}
diff --git a/PlexBar/Support/PlexImageRequest.swift b/PlexBar/Support/PlexImageRequest.swift
new file mode 100644
index 0000000..1373fc8
--- /dev/null
+++ b/PlexBar/Support/PlexImageRequest.swift
@@ -0,0 +1,39 @@
+import PlexModels
+import Foundation
+
+struct PlexImageRequest: Equatable, Sendable {
+ let url: URL
+ let token: String
+
+ init?(path: String?, serverURL: URL?, serverToken: String) {
+ guard let path = path?.nilIfBlank,
+ let serverURL else {
+ return nil
+ }
+
+ if let absoluteURL = URL(string: path), absoluteURL.scheme != nil {
+ guard ["http", "https"].contains(absoluteURL.scheme?.lowercased() ?? ""),
+ absoluteURL.host?.nilIfBlank != nil else {
+ return nil
+ }
+ url = absoluteURL
+ token = Self.hasSameOrigin(absoluteURL, serverURL) ? serverToken : ""
+ } else {
+ guard let relativeURL = PlexURLBuilder.mediaURL(serverURL: serverURL, path: path) else {
+ return nil
+ }
+ url = relativeURL
+ token = serverToken
+ }
+ }
+
+ static func hasSameOrigin(_ lhs: URL, _ rhs: URL) -> Bool {
+ lhs.scheme?.lowercased() == rhs.scheme?.lowercased()
+ && lhs.host?.lowercased() == rhs.host?.lowercased()
+ && effectivePort(lhs) == effectivePort(rhs)
+ }
+
+ private static func effectivePort(_ url: URL) -> Int? {
+ url.port ?? (url.scheme?.lowercased() == "https" ? 443 : 80)
+ }
+}
diff --git a/PlexBar/Support/PlexJWT.swift b/PlexBar/Support/PlexJWT.swift
new file mode 100644
index 0000000..d9c1eff
--- /dev/null
+++ b/PlexBar/Support/PlexJWT.swift
@@ -0,0 +1,164 @@
+import CryptoKit
+import Foundation
+
+struct PlexJSONWebKey: Codable, Equatable, Sendable {
+ let kty: String
+ let crv: String
+ // The JWK field name is defined by RFC 8037.
+ // swiftlint:disable:next identifier_name
+ let x: String
+ let kid: String
+ let use: String?
+ let alg: String
+
+ init(publicKey: Curve25519.Signing.PublicKey, keyID: String, includeUse: Bool) {
+ kty = "OKP"
+ crv = "Ed25519"
+ x = publicKey.rawRepresentation.base64URLEncodedString()
+ kid = keyID
+ use = includeUse ? "sig" : nil
+ alg = "EdDSA"
+ }
+}
+
+struct PlexDeviceSigningIdentity: Equatable, Sendable {
+ private static let tokenLifetime: TimeInterval = 5 * 60
+
+ let keyID: String
+ let privateKeyRepresentation: Data
+
+ init(keyID: String, privateKeyRepresentation: Data) throws {
+ _ = try Curve25519.Signing.PrivateKey(rawRepresentation: privateKeyRepresentation)
+ self.keyID = keyID
+ self.privateKeyRepresentation = privateKeyRepresentation
+ }
+
+ static func generate(keyID: String = UUID().uuidString.lowercased()) throws -> PlexDeviceSigningIdentity {
+ let privateKey = Curve25519.Signing.PrivateKey()
+ return try PlexDeviceSigningIdentity(
+ keyID: keyID,
+ privateKeyRepresentation: privateKey.rawRepresentation
+ )
+ }
+
+ func publicJWK(includeUse: Bool) throws -> PlexJSONWebKey {
+ PlexJSONWebKey(
+ publicKey: try privateKey.publicKey,
+ keyID: keyID,
+ includeUse: includeUse
+ )
+ }
+
+ func signedDeviceJWT(
+ clientIdentifier: String,
+ nonce: String? = nil,
+ scope: String? = nil,
+ issuedAt: Date = Date()
+ ) throws -> String {
+ let header = PlexDeviceJWTHeader(kid: keyID)
+ let claims = PlexDeviceJWTClaims(
+ nonce: nonce,
+ scope: scope,
+ aud: "plex.tv",
+ iss: clientIdentifier,
+ iat: Int(issuedAt.timeIntervalSince1970),
+ exp: Int(issuedAt.addingTimeInterval(Self.tokenLifetime).timeIntervalSince1970)
+ )
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
+ let encodedHeader = try encoder.encode(header).base64URLEncodedString()
+ let encodedClaims = try encoder.encode(claims).base64URLEncodedString()
+ let signingInput = Data("\(encodedHeader).\(encodedClaims)".utf8)
+ let signature = try privateKey.signature(for: signingInput).base64URLEncodedString()
+ return "\(encodedHeader).\(encodedClaims).\(signature)"
+ }
+
+ private var privateKey: Curve25519.Signing.PrivateKey {
+ get throws {
+ try Curve25519.Signing.PrivateKey(rawRepresentation: privateKeyRepresentation)
+ }
+ }
+}
+
+enum PlexAccountToken: Equatable, Sendable {
+ case legacy
+ case jwt(expiresAt: Date)
+
+ init(token: String) throws {
+ let segments = token.split(separator: ".", omittingEmptySubsequences: false)
+ guard segments.count == 3 else {
+ self = .legacy
+ return
+ }
+ guard let payload = Data(base64URLEncoded: String(segments[1])),
+ let claims = try? JSONDecoder().decode(PlexAccountJWTClaims.self, from: payload) else {
+ throw PlexJWTError.malformedAccountToken
+ }
+ self = .jwt(expiresAt: Date(timeIntervalSince1970: TimeInterval(claims.exp)))
+ }
+}
+
+enum PlexJWTError: LocalizedError {
+ case malformedAccountToken
+ case incompleteDeviceIdentity
+ case missingAccountToken
+ case expectedAccountJWT
+ case accountTokenExpiresTooSoon
+ case deviceIdentityPersistenceFailed
+
+ var errorDescription: String? {
+ switch self {
+ case .malformedAccountToken:
+ "The stored Plex account token is not a valid JWT. Sign in to Plex again."
+ case .incompleteDeviceIdentity:
+ "PlexBar found an incomplete device signing identity in Keychain. Sign in to Plex again."
+ case .missingAccountToken:
+ "No Plex account token is available. Sign in to Plex."
+ case .expectedAccountJWT:
+ "Plex returned an account token that is not a JWT. Sign in to Plex again."
+ case .accountTokenExpiresTooSoon:
+ "Plex returned an account token with an invalid expiration time. Sign in to Plex again."
+ case .deviceIdentityPersistenceFailed:
+ "PlexBar could not store its device signing identity in Keychain."
+ }
+ }
+}
+
+private struct PlexDeviceJWTHeader: Encodable {
+ let alg = "EdDSA"
+ let kid: String
+ let typ = "JWT"
+}
+
+private struct PlexDeviceJWTClaims: Encodable {
+ let nonce: String?
+ let scope: String?
+ let aud: String
+ let iss: String
+ let iat: Int
+ let exp: Int
+}
+
+private struct PlexAccountJWTClaims: Decodable {
+ let exp: Int
+}
+
+private extension Data {
+ init?(base64URLEncoded value: String) {
+ var base64 = value
+ .replacingOccurrences(of: "-", with: "+")
+ .replacingOccurrences(of: "_", with: "/")
+ let remainder = base64.count % 4
+ if remainder != 0 {
+ base64.append(String(repeating: "=", count: 4 - remainder))
+ }
+ self.init(base64Encoded: base64)
+ }
+
+ func base64URLEncodedString() -> String {
+ base64EncodedString()
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: "")
+ }
+}
diff --git a/PlexBar/Support/PlexMediaArtworkPresentation.swift b/PlexBar/Support/PlexMediaArtworkPresentation.swift
new file mode 100644
index 0000000..1b4997a
--- /dev/null
+++ b/PlexBar/Support/PlexMediaArtworkPresentation.swift
@@ -0,0 +1,45 @@
+import PlexModels
+import Foundation
+
+enum PlexMediaArtworkLayout: Equatable, Sendable {
+ case automatic
+ case poster
+}
+
+enum PlexMediaArtworkShape: Sendable {
+ case poster
+ case landscape
+ case square
+
+ var aspectRatio: Double {
+ switch self {
+ case .poster: 2.0 / 3.0
+ case .landscape: 16.0 / 9.0
+ case .square: 1
+ }
+ }
+
+ static func automatic(for item: PlexMediaItem) -> Self {
+ switch item.type?.lowercased() {
+ case "episode", "clip": .landscape
+ case "artist", "album", "track", "photo", "photoalbum", "collection", "playlist": .square
+ default: .poster
+ }
+ }
+}
+
+struct PlexMediaArtworkPresentation: Sendable {
+ let shape: PlexMediaArtworkShape
+ let path: String?
+
+ init(item: PlexMediaItem, layout: PlexMediaArtworkLayout = .automatic) {
+ switch layout {
+ case .automatic:
+ shape = .automatic(for: item)
+ path = item.preferredArtworkPath
+ case .poster:
+ shape = .poster
+ path = item.posterArtworkPath
+ }
+ }
+}
diff --git a/PlexBar/Support/PlexMediaMetadataPresentation.swift b/PlexBar/Support/PlexMediaMetadataPresentation.swift
new file mode 100644
index 0000000..e766c9d
--- /dev/null
+++ b/PlexBar/Support/PlexMediaMetadataPresentation.swift
@@ -0,0 +1,203 @@
+import PlexModels
+import Foundation
+
+struct PlexMediaMetadataFact: Equatable, Identifiable, Sendable {
+ enum Kind: String, Sendable {
+ case originalTitle
+ case studio
+ case releaseDate
+ case dimensions
+ case genres
+ case countries
+ }
+
+ let kind: Kind
+ let label: String
+ let value: String
+
+ var id: Kind { kind }
+}
+
+struct PlexMediaMetadataPresentation: Equatable, Sendable {
+ let facts: [PlexMediaMetadataFact]
+
+ init(item: PlexMediaItem, locale: Locale = .autoupdatingCurrent) {
+ var facts: [PlexMediaMetadataFact] = []
+
+ if let originalTitle = item.originalTitle?.nilIfBlank,
+ originalTitle.caseInsensitiveCompare(item.title) != .orderedSame {
+ facts.append(.init(kind: .originalTitle, label: "Original Title", value: originalTitle))
+ }
+
+ if let studio = item.studio?.nilIfBlank {
+ facts.append(.init(kind: .studio, label: item.studioLabel, value: studio))
+ }
+
+ if let releaseDate = Self.formattedReleaseDate(item.originallyAvailableAt, locale: locale) {
+ facts.append(.init(kind: .releaseDate, label: "Released", value: releaseDate))
+ }
+
+ if let dimensions = PlexPhotoPresentation(item: item)?.dimensionsText {
+ facts.append(.init(kind: .dimensions, label: "Dimensions", value: dimensions))
+ }
+
+ Self.appendTags(
+ item.genres,
+ singularLabel: "Genre",
+ pluralLabel: "Genres",
+ kind: .genres,
+ to: &facts
+ )
+ Self.appendTags(
+ item.countries,
+ singularLabel: "Country",
+ pluralLabel: "Countries",
+ kind: .countries,
+ to: &facts
+ )
+
+ self.facts = facts
+ }
+
+ private static func appendTags(
+ _ tags: [PlexTag],
+ singularLabel: String,
+ pluralLabel: String,
+ kind: PlexMediaMetadataFact.Kind,
+ to facts: inout [PlexMediaMetadataFact]
+ ) {
+ let values = uniqueValues(tags.compactMap { $0.tag.nilIfBlank })
+ guard !values.isEmpty else {
+ return
+ }
+
+ facts.append(.init(
+ kind: kind,
+ label: values.count == 1 ? singularLabel : pluralLabel,
+ value: values.joined(separator: ", ")
+ ))
+ }
+
+ private static func uniqueValues(_ values: [String]) -> [String] {
+ var seen: Set = []
+ return values.filter { seen.insert($0).inserted }
+ }
+
+ private static func formattedReleaseDate(_ value: String?, locale: Locale) -> String? {
+ guard let value = value?.nilIfBlank,
+ let timestamp = PlexReleaseTimestamp(value) else {
+ return nil
+ }
+
+ let style = Date.FormatStyle(
+ date: .long,
+ time: timestamp.includesTime ? .standard : .omitted,
+ locale: locale,
+ calendar: Calendar(identifier: .gregorian),
+ timeZone: timestamp.timeZone
+ )
+ return timestamp.date.formatted(style)
+ }
+}
+
+private extension PlexMediaItem {
+ var studioLabel: String {
+ switch type?.lowercased() {
+ case "artist", "album", "track": "Label"
+ default: "Studio"
+ }
+ }
+}
+
+private struct PlexReleaseTimestamp {
+ let date: Date
+ let includesTime: Bool
+ let timeZone: TimeZone
+
+ init?(_ value: String) {
+ let segments = value.split(separator: " ", omittingEmptySubsequences: false)
+ guard segments.count == 1 || segments.count == 2,
+ let dateComponents = Self.parse(
+ segments[0],
+ separator: "-",
+ componentWidths: [4, 2, 2]
+ ) else {
+ return nil
+ }
+
+ let timeComponents: [Int]
+ if segments.count == 2 {
+ guard let parsedTime = Self.parse(
+ segments[1],
+ separator: ":",
+ componentWidths: [2, 2, 2]
+ ) else {
+ return nil
+ }
+ timeComponents = parsedTime
+ } else {
+ timeComponents = [0, 0, 0]
+ }
+
+ guard (0...23).contains(timeComponents[0]),
+ (0...59).contains(timeComponents[1]),
+ (0...59).contains(timeComponents[2]),
+ let timeZone = TimeZone(secondsFromGMT: 0) else {
+ return nil
+ }
+
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = timeZone
+ let expected = DateComponents(
+ timeZone: timeZone,
+ year: dateComponents[0],
+ month: dateComponents[1],
+ day: dateComponents[2],
+ hour: timeComponents[0],
+ minute: timeComponents[1],
+ second: timeComponents[2]
+ )
+ guard let date = calendar.date(from: expected) else {
+ return nil
+ }
+
+ let actual = calendar.dateComponents(
+ [.year, .month, .day, .hour, .minute, .second],
+ from: date
+ )
+ guard actual.year == expected.year,
+ actual.month == expected.month,
+ actual.day == expected.day,
+ actual.hour == expected.hour,
+ actual.minute == expected.minute,
+ actual.second == expected.second else {
+ return nil
+ }
+
+ self.date = date
+ includesTime = segments.count == 2
+ self.timeZone = timeZone
+ }
+
+ private static func parse(
+ _ value: Substring,
+ separator: Character,
+ componentWidths: [Int]
+ ) -> [Int]? {
+ let components = value.split(separator: separator, omittingEmptySubsequences: false)
+ guard components.count == componentWidths.count else {
+ return nil
+ }
+
+ var values: [Int] = []
+ for (component, width) in zip(components, componentWidths) {
+ guard component.count == width,
+ component.allSatisfy(\.isNumber),
+ let integer = Int(component) else {
+ return nil
+ }
+ values.append(integer)
+ }
+ return values
+ }
+}
diff --git a/PlexBar/Support/PlexMediaSourceURI.swift b/PlexBar/Support/PlexMediaSourceURI.swift
new file mode 100644
index 0000000..b650229
--- /dev/null
+++ b/PlexBar/Support/PlexMediaSourceURI.swift
@@ -0,0 +1,17 @@
+import PlexModels
+import Foundation
+
+enum PlexMediaSourceURI {
+ static func item(
+ _ item: PlexMediaItem,
+ serverIdentifier: String,
+ providerIdentifier: String = PlexMediaProvider.libraryIdentifier
+ ) throws -> String {
+ guard let serverIdentifier = serverIdentifier.nilIfBlank,
+ let itemKey = item.key?.nilIfBlank,
+ itemKey.hasPrefix("/") else {
+ throw PlexAPIError.missingServerIdentity
+ }
+ return "server://\(serverIdentifier)/\(providerIdentifier)/\(itemKey.dropFirst())"
+ }
+}
diff --git a/PlexBar/Support/PlexMediaSummaryPresentation.swift b/PlexBar/Support/PlexMediaSummaryPresentation.swift
new file mode 100644
index 0000000..c18a259
--- /dev/null
+++ b/PlexBar/Support/PlexMediaSummaryPresentation.swift
@@ -0,0 +1,70 @@
+import PlexModels
+import Foundation
+
+struct PlexMediaFactsPresentation {
+ let facts: [String]
+ let contentRating: String?
+
+ init(facts: [String?], contentRating: String?) {
+ self.facts = facts.compactMap { $0?.nilIfBlank }
+ self.contentRating = contentRating?.nilIfBlank
+ }
+
+ func plainText(separator: String = " · ") -> String? {
+ (facts + [contentRating].compactMap { $0 }).joined(separator: separator).nilIfBlank
+ }
+}
+
+/// Summary content shared by the Mac and TV detail layouts.
+struct PlexMediaSummaryPresentation {
+ let item: PlexMediaItem
+
+ var episodeHeading: String {
+ PlexEpisodeText.subtitle(season: item.parentIndex, episode: item.index, title: item.title)
+ }
+
+ var episodeFacts: String? {
+ episodeFactsPresentation.plainText(separator: " · ")
+ }
+
+ var episodeFactsPresentation: PlexMediaFactsPresentation {
+ PlexMediaFactsPresentation(
+ facts: [
+ item.formattedDuration,
+ PlexMediaMetadataPresentation(item: item).facts.first { $0.kind == .releaseDate }?.value,
+ ],
+ contentRating: item.contentRating
+ )
+ }
+
+ var heroFacts: String? {
+ item.factsLine
+ }
+
+ var genres: String? {
+ item.genres.prefix(3).map(\.tag).joined(separator: ", ").nilIfBlank
+ }
+
+ var detailFacts: String? {
+ item.type?.lowercased() == "episode" ? episodeFacts : heroFacts
+ }
+}
+
+extension PlexMediaItem {
+ var episodeIdentifier: String? {
+ guard type?.lowercased() == "episode", let index else { return nil }
+ if let parentIndex { return "S\(parentIndex), E\(index)" }
+ return "Episode \(index)"
+ }
+
+ var factsLine: String? {
+ factsPresentation.plainText()
+ }
+
+ var factsPresentation: PlexMediaFactsPresentation {
+ PlexMediaFactsPresentation(
+ facts: [episodeIdentifier, year.map(String.init), formattedDuration],
+ contentRating: contentRating
+ )
+ }
+}
diff --git a/PlexBar/Support/PlexMockResources.swift b/PlexBar/Support/PlexMockResources.swift
new file mode 100644
index 0000000..5b90624
--- /dev/null
+++ b/PlexBar/Support/PlexMockResources.swift
@@ -0,0 +1,38 @@
+import Foundation
+
+#if DEBUG
+import PlexMockData
+
+enum PlexMockServerPayloadError: Error {
+ case missingResource
+}
+
+enum PlexMockServerResourceLocator {
+ static func url(for relativePath: String) -> URL {
+ URL(fileURLWithPath: #filePath)
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ .appending(path: "Resources/MockServer/\(relativePath)")
+ }
+}
+
+extension PlexMockServerPayload {
+ static func loadDefault() throws -> PlexMockServerPayload {
+ let url = PlexMockServerResourceLocator.url(for: "mock-server.json")
+ guard FileManager.default.fileExists(atPath: url.path) else {
+ throw PlexMockServerPayloadError.missingResource
+ }
+
+ let data = try Data(contentsOf: url)
+ let payload = try JSONDecoder().decode(PlexMockServerPayload.self, from: data)
+ try payload.validateProfiles()
+ return payload
+ }
+}
+
+extension PlexMockMediaCatalog {
+ static func loadDefault() throws -> Self {
+ try Self(data: Data(contentsOf: PlexMockServerResourceLocator.url(for: "media-catalog.json")))
+ }
+}
+#endif
diff --git a/PlexBar/Support/PlexMotion.swift b/PlexBar/Support/PlexMotion.swift
new file mode 100644
index 0000000..ed104d4
--- /dev/null
+++ b/PlexBar/Support/PlexMotion.swift
@@ -0,0 +1,13 @@
+import SwiftUI
+
+enum PlexMotion {
+ static var surfaceTransition: AnyTransition { .opacity }
+
+ static func surfaceAnimation(reduceMotion: Bool) -> Animation? {
+ reduceMotion ? nil : .easeOut(duration: 0.2)
+ }
+
+ static func contentReplacementAnimation(reduceMotion: Bool) -> Animation? {
+ reduceMotion ? nil : .easeOut(duration: 0.16)
+ }
+}
diff --git a/PlexBar/Support/PlexPersonalRating.swift b/PlexBar/Support/PlexPersonalRating.swift
new file mode 100644
index 0000000..d3feff5
--- /dev/null
+++ b/PlexBar/Support/PlexPersonalRating.swift
@@ -0,0 +1,75 @@
+import Foundation
+
+enum PlexPersonalRating {
+ static let starCount = 5
+ static let halfStarStep = 0.5
+ static let minimumServerValue = 1.0
+ static let maximumServerValue = 10.0
+
+ static func stars(fromServerValue serverValue: Double?) -> Double? {
+ guard let serverValue,
+ serverValue.isFinite,
+ (minimumServerValue...maximumServerValue).contains(serverValue) else {
+ return nil
+ }
+ return serverValue / 2
+ }
+
+ static func serverValue(fromStars stars: Double) -> Double? {
+ guard stars.isFinite,
+ (halfStarStep...Double(starCount)).contains(stars) else {
+ return nil
+ }
+
+ let halfStarSteps = (stars / halfStarStep).rounded(.toNearestOrAwayFromZero)
+ return halfStarSteps
+ }
+
+ static func stars(at locationX: Double, controlWidth: Double) -> Double? {
+ guard locationX.isFinite,
+ controlWidth.isFinite,
+ controlWidth > 0 else {
+ return nil
+ }
+
+ let clampedX = min(max(locationX, 0), controlWidth)
+ let halfStarSteps = max(1, ceil((clampedX / controlWidth) * maximumServerValue))
+ return halfStarSteps * halfStarStep
+ }
+
+ static func adjustedServerValue(from currentValue: Double?, by step: Int) -> Double? {
+ guard step != 0 else {
+ return currentValue
+ }
+
+ let currentStep = stars(fromServerValue: currentValue)
+ .flatMap(serverValue(fromStars:)) ?? 0
+ let adjustedStep = currentStep + Double(step.signum())
+
+ if adjustedStep < minimumServerValue {
+ return nil
+ }
+ return min(adjustedStep, maximumServerValue)
+ }
+
+ static func title(forServerValue serverValue: Double?) -> String {
+ guard let stars = stars(fromServerValue: serverValue) else {
+ return "Rate"
+ }
+ return title(forStars: stars)
+ }
+
+ static func title(forStars stars: Double) -> String {
+ let formattedStars = stars.formatted(
+ .number.precision(.fractionLength(0...2))
+ )
+ return "\(formattedStars) \(stars == 1 ? "Star" : "Stars")"
+ }
+
+ static func accessibilityValue(forServerValue serverValue: Double?) -> String {
+ guard let stars = stars(fromServerValue: serverValue) else {
+ return "No rating"
+ }
+ return title(forStars: stars).lowercased()
+ }
+}
diff --git a/PlexBar/Support/PlexPhotoPresentation.swift b/PlexBar/Support/PlexPhotoPresentation.swift
new file mode 100644
index 0000000..c934be3
--- /dev/null
+++ b/PlexBar/Support/PlexPhotoPresentation.swift
@@ -0,0 +1,70 @@
+import PlexModels
+import CoreGraphics
+import Foundation
+
+struct PlexPhotoPresentation: Equatable, Sendable {
+ let sourcePath: String?
+ let fallbackArtworkPath: String?
+ let pixelWidth: Int?
+ let pixelHeight: Int?
+
+ init?(item: PlexMediaItem) {
+ guard item.type?.lowercased() == "photo" else {
+ return nil
+ }
+
+ let media = item.media.first(where: {
+ $0.selected == true && Self.sourcePart(in: $0) != nil
+ })
+ ?? item.media.first(where: { Self.sourcePart(in: $0) != nil })
+ ?? item.media.first(where: { $0.selected == true })
+ ?? item.media.first
+ let part = media?.parts.first(where: {
+ $0.selected == true && $0.key?.nilIfBlank != nil
+ }) ?? media?.parts.first(where: { $0.key?.nilIfBlank != nil })
+ let artworkPath = item.thumb?.nilIfBlank ?? item.composite?.nilIfBlank
+ let sourcePath = part?.key?.nilIfBlank ?? artworkPath
+
+ self.sourcePath = sourcePath
+ fallbackArtworkPath = sourcePath != artworkPath ? artworkPath : nil
+ pixelWidth = media?.width.flatMap { $0 > 0 ? $0 : nil }
+ pixelHeight = media?.height.flatMap { $0 > 0 ? $0 : nil }
+ }
+
+ var dimensionsText: String? {
+ guard let pixelWidth, let pixelHeight else {
+ return nil
+ }
+ return "\(pixelWidth.formatted()) × \(pixelHeight.formatted())"
+ }
+
+ func fittedSize(in availableSize: CGSize) -> CGSize {
+ let availableWidth = max(availableSize.width, 0)
+ let availableHeight = max(availableSize.height, 0)
+ guard availableWidth > 0, availableHeight > 0,
+ let pixelWidth, let pixelHeight else {
+ return CGSize(width: availableWidth, height: availableHeight)
+ }
+
+ let scale = min(
+ availableWidth / CGFloat(pixelWidth),
+ availableHeight / CGFloat(pixelHeight)
+ )
+ return CGSize(
+ width: CGFloat(pixelWidth) * scale,
+ height: CGFloat(pixelHeight) * scale
+ )
+ }
+
+ func requestPixelSize(for displaySize: CGSize, displayScale: CGFloat) -> CGSize {
+ let scale = displayScale.isFinite ? max(displayScale, 1) : 1
+ return CGSize(
+ width: max(ceil(displaySize.width * scale), 1),
+ height: max(ceil(displaySize.height * scale), 1)
+ )
+ }
+
+ private static func sourcePart(in media: PlexMediaVersion) -> PlexMediaPart? {
+ media.parts.first(where: { $0.key?.nilIfBlank != nil })
+ }
+}
diff --git a/PlexBar/Support/PlexPlaybackSleepTimer.swift b/PlexBar/Support/PlexPlaybackSleepTimer.swift
new file mode 100644
index 0000000..095d0f5
--- /dev/null
+++ b/PlexBar/Support/PlexPlaybackSleepTimer.swift
@@ -0,0 +1,65 @@
+import Foundation
+
+enum PlexPlaybackSleepTimerPreset: String, CaseIterable, Identifiable, Sendable {
+ case off
+ case fifteenMinutes
+ case thirtyMinutes
+ case fortyFiveMinutes
+ case oneHour
+ case endOfItem
+
+ var id: Self { self }
+
+ var label: String {
+ switch self {
+ case .off: "Off"
+ case .fifteenMinutes: "15 Minutes"
+ case .thirtyMinutes: "30 Minutes"
+ case .fortyFiveMinutes: "45 Minutes"
+ case .oneHour: "1 Hour"
+ case .endOfItem: "End of Current Item"
+ }
+ }
+
+ fileprivate var duration: TimeInterval? {
+ switch self {
+ case .off, .endOfItem: nil
+ case .fifteenMinutes: 15 * 60
+ case .thirtyMinutes: 30 * 60
+ case .fortyFiveMinutes: 45 * 60
+ case .oneHour: 60 * 60
+ }
+ }
+}
+
+struct PlexPlaybackSleepTimer: Equatable, Sendable {
+ static let off = Self(preset: .off)
+
+ let preset: PlexPlaybackSleepTimerPreset
+ let deadline: Date?
+
+ init(
+ preset: PlexPlaybackSleepTimerPreset,
+ startingAt date: Date = .now
+ ) {
+ self.preset = preset
+ deadline = preset.duration.map { date.addingTimeInterval($0) }
+ }
+
+ var isActive: Bool {
+ preset != .off
+ }
+
+ var stopsAtEndOfItem: Bool {
+ preset == .endOfItem
+ }
+
+ func remainingTime(at date: Date = .now) -> TimeInterval? {
+ deadline.map { max($0.timeIntervalSince(date), 0) }
+ }
+
+ func hasExpired(at date: Date = .now) -> Bool {
+ guard let deadline else { return false }
+ return deadline <= date
+ }
+}
diff --git a/Sources/PlexBar/Support/PlexRemoteService.swift b/PlexBar/Support/PlexRemoteService.swift
similarity index 54%
rename from Sources/PlexBar/Support/PlexRemoteService.swift
rename to PlexBar/Support/PlexRemoteService.swift
index 73eceea..cbd43d6 100644
--- a/Sources/PlexBar/Support/PlexRemoteService.swift
+++ b/PlexBar/Support/PlexRemoteService.swift
@@ -1,7 +1,9 @@
+import PlexModels
import Foundation
enum PlexRemoteService {
static let apiBaseURL = URL(string: "https://plex.tv")!
+ static let clientsBaseURL = URL(string: "https://clients.plex.tv")!
static let authAppBaseURL = URL(string: "https://app.plex.tv")!
static let websiteURL = URL(string: "https://www.plex.tv/")!
@@ -12,10 +14,29 @@ enum PlexRemoteService {
return components.url!
}
+ static func clientsURL(path: String, queryItems: [URLQueryItem] = []) -> URL {
+ var components = URLComponents(url: clientsBaseURL, resolvingAgainstBaseURL: false)!
+ components.path = path
+ components.queryItems = queryItems.isEmpty ? nil : queryItems
+ return components.url!
+ }
+
static func authURL(query: String) -> URL {
URL(string: authAppBaseURL.absoluteString + "/auth/#!?\(query)")!
}
+ static func linkURL(pinCode: String) -> URL? {
+ guard let pinCode = pinCode
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .nilIfBlank else {
+ return nil
+ }
+ return apiURL(
+ path: "/link/",
+ queryItems: [URLQueryItem(name: "pin", value: pinCode)]
+ )
+ }
+
static func isPlexHosted(_ url: URL) -> Bool {
guard let host = url.host?.lowercased() else {
return false
diff --git a/Sources/PlexBar/Support/PlexRequestBuilder.swift b/PlexBar/Support/PlexRequestBuilder.swift
similarity index 77%
rename from Sources/PlexBar/Support/PlexRequestBuilder.swift
rename to PlexBar/Support/PlexRequestBuilder.swift
index 1af3ec9..0dc6af8 100644
--- a/Sources/PlexBar/Support/PlexRequestBuilder.swift
+++ b/PlexBar/Support/PlexRequestBuilder.swift
@@ -1,3 +1,4 @@
+import PlexModels
import Foundation
struct PlexRequestBuilder {
@@ -22,6 +23,13 @@ struct PlexRequestBuilder {
request.setValue(value, forHTTPHeaderField: key)
}
+ if !PlexRemoteService.isPlexHosted(url) {
+ request.setValue(
+ PlexClientContext.pmsAPIVersion,
+ forHTTPHeaderField: "X-Plex-Pms-Api-Version"
+ )
+ }
+
if let token = token?.nilIfBlank {
request.setValue(token, forHTTPHeaderField: "X-Plex-Token")
}
diff --git a/PlexBar/Support/PlexRewindOnResume.swift b/PlexBar/Support/PlexRewindOnResume.swift
new file mode 100644
index 0000000..e3ed758
--- /dev/null
+++ b/PlexBar/Support/PlexRewindOnResume.swift
@@ -0,0 +1,70 @@
+import Foundation
+
+struct PlexRewindOnResume: Equatable, Sendable {
+ static let secondsRange = 0...30
+ static let none = PlexRewindOnResume(seconds: 0)
+
+ let seconds: Int
+
+ init(seconds: Int) {
+ self.seconds = min(max(seconds, Self.secondsRange.lowerBound), Self.secondsRange.upperBound)
+ }
+
+ var label: String {
+ switch seconds {
+ case 0:
+ "None"
+ case 1:
+ "1 Second"
+ default:
+ "\(seconds) Seconds"
+ }
+ }
+
+ func target(from position: TimeInterval) -> TimeInterval? {
+ guard seconds > 0, position.isFinite, position > 0 else {
+ return nil
+ }
+ return max(position - TimeInterval(seconds), 0)
+ }
+}
+
+enum PlexRewindOnResumeAction: Equatable, Sendable {
+ case playImmediately
+ case seekThenPlay(target: TimeInterval)
+}
+
+enum PlexRewindOnResumePolicy {
+ static func action(
+ status: PlexPlaybackStatus,
+ position: TimeInterval,
+ preference: PlexRewindOnResume
+ ) -> PlexRewindOnResumeAction? {
+ guard status == .paused else {
+ return nil
+ }
+ guard let target = preference.target(from: position) else {
+ return .playImmediately
+ }
+ return .seekThenPlay(target: target)
+ }
+
+ static func transportAction(
+ status: PlexPlaybackStatus,
+ hasPendingRewind: Bool
+ ) -> PlexPlaybackTransportAction? {
+ hasPendingRewind ? .pause : PlexPlaybackTransportAction(status: status)
+ }
+
+ static func shouldInterceptNativePlayPausePress(
+ status: PlexPlaybackStatus,
+ hasPendingRewind: Bool,
+ isPlaybackControlBusy: Bool,
+ preference: PlexRewindOnResume
+ ) -> Bool {
+ if hasPendingRewind || isPlaybackControlBusy {
+ return true
+ }
+ return status == .paused && preference.seconds > 0
+ }
+}
diff --git a/PlexBar/Support/PlexSessionPlaybackDetails.swift b/PlexBar/Support/PlexSessionPlaybackDetails.swift
new file mode 100644
index 0000000..b636783
--- /dev/null
+++ b/PlexBar/Support/PlexSessionPlaybackDetails.swift
@@ -0,0 +1,118 @@
+import PlexModels
+import Foundation
+
+struct PlexSessionPlaybackDetails {
+ struct Row: Identifiable {
+ let title: String
+ let source: String
+ let output: String?
+ var id: String { title }
+ }
+
+ let method: String
+ let bandwidth: String?
+ let usesHardware: Bool
+ let rows: [Row]
+
+ init(session: PlexSession) {
+ switch session.deliveryMethod {
+ case .directPlay: method = "Direct Play"
+ case .directStream: method = "Direct Stream"
+ case .transcoding: method = "Transcoding"
+ case .unknown: method = "Unknown"
+ }
+ if let value = session.session?.bandwidth, value >= 0 {
+ bandwidth = PlexActivitySummary.bandwidthText(kbps: Double(value))
+ } else {
+ bandwidth = nil
+ }
+ let transcode = session.transcodeSession
+ usesHardware = session.deliveryMethod == .transcoding
+ && transcode?.videoDecision?.lowercased() == "transcode"
+ && [transcode?.transcodeHwDecoding, transcode?.transcodeHwEncoding].contains {
+ guard let engine = $0?.nilIfBlank?.lowercased() else { return false }
+ return engine != "none"
+ }
+
+ var details: [Row] = []
+ for (type, title) in [(1, "Video"), (2, "Audio")] {
+ let stream = session.activePlaybackStream(type: type)
+ let sourceCodec = type == 1 ? transcode?.sourceVideoCodec : transcode?.sourceAudioCodec
+ let outputCodec = type == 1 ? transcode?.videoCodec : transcode?.audioCodec
+ let outputDecision = type == 1 ? transcode?.videoDecision : transcode?.audioDecision
+ let hasTrack = session.activePlaybackPart?.stream?.contains { $0.streamType == type } == true
+ guard hasTrack || outputCodec?.nilIfBlank != nil else { continue }
+
+ let decision = session.isLive && transcode != nil ? outputDecision : stream?.decision
+ let converting = decision?.lowercased() == "transcode"
+ // Plex retains the source display title on the selected output stream.
+ // When it is absent, never substitute the output codec for the source.
+ let source = stream?.displayTitle?.nilIfBlank
+ ?? Self.sourceDescription(language: type == 2 ? stream?.language : nil,
+ codec: converting ? sourceCodec : stream?.codec)
+ ?? "Unavailable"
+ let bitrate = !session.isLive || stream?.decision?.lowercased() == decision?.lowercased()
+ ? stream?.bitrate : nil
+ let output = Self.outputDescription(decision: decision,
+ codec: session.isLive ? outputCodec : stream?.codec ?? outputCodec,
+ bitrate: bitrate, hardware: type == 1 && usesHardware)
+ details.append(Row(title: title, source: source, output: output))
+ }
+ if details.isEmpty {
+ details.append(Row(title: session.contentKind == .track ? "Audio" : "Video", source: "Unavailable", output: nil))
+ }
+ if session.contentKind != .track {
+ if let subtitle = session.activePlaybackStream(type: 3) {
+ let source = subtitle.displayTitle?.nilIfBlank
+ ?? Self.sourceDescription(language: subtitle.language,
+ codec: subtitle.decision?.lowercased() == "transcode" ? nil : subtitle.codec)
+ ?? "Unavailable"
+ let output: String?
+ switch subtitle.decision?.lowercased() {
+ case "burn": output = "Burn In"
+ case "transcode": output = Self.codecName(subtitle.codec) ?? "Transcode"
+ default: output = nil
+ }
+ details.append(Row(title: "Subtitles", source: source, output: output))
+ } else {
+ let hasSelectedSubtitle = session.activePlaybackPart?.stream?.contains { $0.streamType == 3 && $0.selected == true } == true
+ details.append(Row(title: "Subtitles", source: session.activePlaybackPart == nil || hasSelectedSubtitle ? "Unavailable" : "None", output: nil))
+ }
+ }
+ rows = details
+ }
+
+ private static func sourceDescription(language: String?, codec: String?) -> String? {
+ [language?.nilIfBlank, codecName(codec)].compactMap { $0 }.joined(separator: " · ").nilIfBlank
+ }
+
+ private static func outputDescription(decision: String?, codec: String?, bitrate: Int?, hardware: Bool) -> String? {
+ let description: String
+ switch decision?.nilIfBlank?.lowercased() {
+ case "transcode": description = codecName(codec) ?? "Transcode"
+ case "copy": description = "Direct Stream"
+ default: return nil
+ }
+ var pieces = [description]
+ if let bitrate, bitrate > 0 { pieces.append(trackBitrateText(kbps: bitrate)) }
+ if hardware { pieces.append("HW") }
+ return pieces.joined(separator: " · ")
+ }
+
+ static func trackBitrateText(kbps: Int, locale: Locale = .autoupdatingCurrent) -> String {
+ let format = FloatingPointFormatStyle.number
+ .precision(.fractionLength(0...1))
+ .locale(locale)
+ if kbps < 1_000 { return "\(Double(kbps).formatted(format)) Kbps" }
+ return "\((Double(kbps) / 1_000).formatted(format)) Mbps"
+ }
+
+ private static func codecName(_ codec: String?) -> String? {
+ guard let codec = codec?.nilIfBlank, codec != "*" else { return nil }
+ switch codec.lowercased() {
+ case "h264": return "H.264"
+ case "h265", "hevc": return "HEVC"
+ default: return codec.uppercased()
+ }
+ }
+}
diff --git a/Sources/PlexBar/Support/PlexSystemLifecycleObserver.swift b/PlexBar/Support/PlexSystemLifecycleObserver.swift
similarity index 58%
rename from Sources/PlexBar/Support/PlexSystemLifecycleObserver.swift
rename to PlexBar/Support/PlexSystemLifecycleObserver.swift
index ca22f1d..7db2f64 100644
--- a/Sources/PlexBar/Support/PlexSystemLifecycleObserver.swift
+++ b/PlexBar/Support/PlexSystemLifecycleObserver.swift
@@ -3,24 +3,32 @@ import AppKit
final class PlexSystemLifecycleObserver {
private let notificationCenter: NotificationCenter
private let didWakeObserver: NSObjectProtocol
+ private let willSleepObserver: NSObjectProtocol
init(
notificationCenter: NotificationCenter = NSWorkspace.shared.notificationCenter,
+ onWillSleep: @escaping @MainActor () -> Void,
onDidWake: @escaping @MainActor () -> Void
) {
self.notificationCenter = notificationCenter
+ willSleepObserver = notificationCenter.addObserver(
+ forName: NSWorkspace.willSleepNotification,
+ object: nil,
+ queue: .main
+ ) { _ in
+ MainActor.assumeIsolated { onWillSleep() }
+ }
didWakeObserver = notificationCenter.addObserver(
forName: NSWorkspace.didWakeNotification,
object: nil,
queue: .main
) { _ in
- Task { @MainActor in
- onDidWake()
- }
+ MainActor.assumeIsolated { onDidWake() }
}
}
deinit {
notificationCenter.removeObserver(didWakeObserver)
+ notificationCenter.removeObserver(willSleepObserver)
}
}
diff --git a/PlexBar/Support/PlexURLBuilder.swift b/PlexBar/Support/PlexURLBuilder.swift
new file mode 100644
index 0000000..15f709c
--- /dev/null
+++ b/PlexBar/Support/PlexURLBuilder.swift
@@ -0,0 +1,152 @@
+import PlexModels
+import Foundation
+
+enum PlexURLBuilder {
+ static func normalizeServerURL(_ rawValue: String) -> URL? {
+ let trimmedValue = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedValue.isEmpty else {
+ return nil
+ }
+
+ let candidate = trimmedValue.contains("://") ? trimmedValue : "http://\(trimmedValue)"
+ guard var components = URLComponents(string: candidate),
+ components.host?.isEmpty == false else {
+ return nil
+ }
+
+ if components.path == "/" {
+ components.path = ""
+ } else {
+ components.path = components.path.trimmingTrailingSlash()
+ }
+
+ return components.url
+ }
+
+ static func endpointURL(serverURL: URL, path: String) -> URL? {
+ guard var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false),
+ let pathComponents = URLComponents(string: path),
+ pathComponents.scheme == nil,
+ pathComponents.host == nil else {
+ return nil
+ }
+
+ let basePath = components.path.trimmingSlashes()
+ let relativePath = pathComponents.path.trimmingSlashes()
+ let combinedPath = [basePath, relativePath]
+ .filter { !$0.isEmpty }
+ .joined(separator: "/")
+
+ components.path = "/" + combinedPath
+ components.queryItems = pathComponents.queryItems
+ return components.url
+ }
+
+ static func endpointURL(
+ serverURL: URL,
+ path: String,
+ appendingPathComponent pathComponent: String
+ ) -> URL? {
+ endpointURL(
+ serverURL: serverURL,
+ path: path,
+ appendingPathComponents: [pathComponent]
+ )
+ }
+
+ static func endpointURL(
+ serverURL: URL,
+ path: String,
+ appendingPathComponents appendedPathComponents: [String]
+ ) -> URL? {
+ guard var pathComponents = URLComponents(string: path),
+ pathComponents.scheme == nil,
+ pathComponents.host == nil,
+ !appendedPathComponents.isEmpty else {
+ return nil
+ }
+
+ let normalizedPathComponents = appendedPathComponents.compactMap { pathComponent -> String? in
+ guard let pathComponent = pathComponent.nilIfBlank,
+ !pathComponent.contains("/") else {
+ return nil
+ }
+ return pathComponent
+ }
+ guard normalizedPathComponents.count == appendedPathComponents.count else {
+ return nil
+ }
+
+ let basePath = pathComponents.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
+ pathComponents.path = "/" + ([basePath] + normalizedPathComponents)
+ .filter { !$0.isEmpty }
+ .joined(separator: "/")
+
+ guard let appendedPath = pathComponents.string else {
+ return nil
+ }
+ return endpointURL(serverURL: serverURL, path: appendedPath)
+ }
+
+ static func mediaURL(serverURL: URL, path: String?) -> URL? {
+ guard let path = path?.nilIfBlank else {
+ return nil
+ }
+
+ return endpointURL(serverURL: serverURL, path: path)
+ }
+
+ static func transcodedArtworkURL(serverURL: URL, path: String?, width: Int, height: Int) -> URL? {
+ guard let path = path?.nilIfBlank,
+ var components = endpointURL(serverURL: serverURL, path: "/photo/:/transcode")
+ .flatMap({ URLComponents(url: $0, resolvingAgainstBaseURL: false) }) else {
+ return nil
+ }
+
+ components.queryItems = [
+ URLQueryItem(name: "url", value: path),
+ URLQueryItem(name: "width", value: String(width)),
+ URLQueryItem(name: "height", value: String(height)),
+ URLQueryItem(name: "minSize", value: "1"),
+ URLQueryItem(name: "upscale", value: "1"),
+ URLQueryItem(name: "format", value: "jpeg")
+ ]
+ return components.url
+ }
+
+ static func transcodedPhotoURL(serverURL: URL, path: String?, width: Int, height: Int) -> URL? {
+ guard let path = path?.nilIfBlank,
+ width > 0,
+ height > 0,
+ var components = endpointURL(serverURL: serverURL, path: "/photo/:/transcode")
+ .flatMap({ URLComponents(url: $0, resolvingAgainstBaseURL: false) }) else {
+ return nil
+ }
+
+ components.queryItems = [
+ URLQueryItem(name: "url", value: path),
+ URLQueryItem(name: "width", value: String(width)),
+ URLQueryItem(name: "height", value: String(height)),
+ URLQueryItem(name: "minSize", value: "0"),
+ URLQueryItem(name: "upscale", value: "0"),
+ URLQueryItem(name: "rotate", value: "1"),
+ URLQueryItem(name: "quality", value: "-1"),
+ URLQueryItem(name: "format", value: "jpeg"),
+ ]
+ return components.url
+ }
+}
+
+extension String {
+ fileprivate func trimmingSlashes() -> String {
+ trimmingCharacters(in: CharacterSet(charactersIn: "/"))
+ }
+
+ fileprivate func trimmingTrailingSlash() -> String {
+ guard hasSuffix("/") else {
+ return self
+ }
+
+ return String(dropLast())
+ }
+}
diff --git a/PlexBar/TV/App/PlexBarTVApp.swift b/PlexBar/TV/App/PlexBarTVApp.swift
new file mode 100644
index 0000000..48f45c3
--- /dev/null
+++ b/PlexBar/TV/App/PlexBarTVApp.swift
@@ -0,0 +1,83 @@
+import SwiftUI
+
+@main
+struct PlexBarTVApp: App {
+ @State private var store = TVAppStore()
+
+ var body: some Scene {
+ WindowGroup {
+ TVRootView()
+ .environment(store)
+ .preferredColorScheme(.dark)
+ }
+ }
+}
+
+private struct TVRootView: View {
+ @Environment(TVAppStore.self) private var store
+ @Environment(\.scenePhase) private var scenePhase
+
+ var body: some View {
+ @Bindable var store = store
+
+ Group {
+ if store.isConnected {
+ TVMainTabView()
+ } else {
+ TVConnectionView()
+ }
+ }
+ .task {
+ await store.restoreSession()
+ if !store.isConnected, !store.hasAuthorizedAccount {
+ store.startPlexDeviceAuthorization()
+ }
+ }
+ .onOpenURL(perform: store.openTopShelfURL)
+ .onChange(of: scenePhase) { _, newPhase in
+ if newPhase == .active, store.isConnected, !store.isLoadingHome {
+ Task { await store.refreshAll() }
+ }
+ }
+ .fullScreenCover(item: $store.playbackRequest, onDismiss: store.dismissPlayer) { request in
+ TVPlayerView(request: request)
+ .environment(store)
+ }
+ .alert("PlexBar", isPresented: Binding(
+ get: { store.errorMessage != nil },
+ set: { if !$0 { store.errorMessage = nil } }
+ )) {
+ Button("OK") { store.errorMessage = nil }
+ } message: {
+ Text(store.errorMessage ?? "Something went wrong.")
+ }
+ }
+}
+
+private struct TVMainTabView: View {
+ @Environment(TVAppStore.self) private var store
+
+ var body: some View {
+ @Bindable var store = store
+
+ TabView(selection: $store.selectedTab) {
+ Tab("Home", systemImage: "house.fill", value: TVAppStore.Tab.home) {
+ TVHomeView()
+ }
+ Tab("Libraries", systemImage: "rectangle.stack.fill", value: TVAppStore.Tab.libraries) {
+ TVLibrariesView()
+ }
+ Tab(
+ "Search",
+ systemImage: "magnifyingglass",
+ value: TVAppStore.Tab.search,
+ role: .search
+ ) {
+ TVSearchView()
+ }
+ Tab("Settings", systemImage: "gearshape.fill", value: TVAppStore.Tab.settings) {
+ TVSettingsView()
+ }
+ }
+ }
+}
diff --git a/PlexBar/TV/Models/TVHomeContent.swift b/PlexBar/TV/Models/TVHomeContent.swift
new file mode 100644
index 0000000..516e735
--- /dev/null
+++ b/PlexBar/TV/Models/TVHomeContent.swift
@@ -0,0 +1,21 @@
+import PlexModels
+import Foundation
+
+enum TVHomeContent {
+ static func videoItems(_ items: [PlexMediaItem]) -> [PlexMediaItem] {
+ items.filter { item in
+ switch item.type?.lowercased() {
+ case "movie", "show", "season", "episode": true
+ default: false
+ }
+ }
+ }
+
+ static func hubs(_ hubs: [PlexHub]) -> [PlexHub] {
+ hubs.compactMap { hub in
+ var hub = hub
+ hub.metadata = videoItems(hub.metadata)
+ return hub.metadata.isEmpty ? nil : hub
+ }
+ }
+}
diff --git a/PlexBar/TV/Models/TVNavigationRoute.swift b/PlexBar/TV/Models/TVNavigationRoute.swift
new file mode 100644
index 0000000..ccffcd6
--- /dev/null
+++ b/PlexBar/TV/Models/TVNavigationRoute.swift
@@ -0,0 +1,7 @@
+import PlexModels
+import Foundation
+
+enum TVNavigationRoute: Hashable {
+ case media(PlexMediaItem)
+ case person(PlexPersonRoute)
+}
diff --git a/PlexBar/TV/Models/TVPlexModels.swift b/PlexBar/TV/Models/TVPlexModels.swift
new file mode 100644
index 0000000..e262ee2
--- /dev/null
+++ b/PlexBar/TV/Models/TVPlexModels.swift
@@ -0,0 +1,235 @@
+import PlexModels
+import Foundation
+
+struct TVPlexConnection: Equatable, Sendable {
+ let serverURL: URL
+ let token: String
+ let clientIdentifier: String
+ let serverIdentifier: String
+ let kind: PlexConnectionKind
+}
+
+struct TVPlexServerIdentity: Decodable, Sendable {
+ let machineIdentifier: String?
+ let friendlyName: String?
+ let version: String?
+}
+
+struct TVPlexResolvedServer: Sendable {
+ let connection: TVPlexConnection
+ let identity: TVPlexServerIdentity
+}
+
+enum TVPlaybackPreparationKind: Equatable, Sendable {
+ case content
+ case primaryExtra
+}
+
+struct TVPlaybackPreparation: Equatable, Sendable {
+ let itemRatingKey: String
+ let kind: TVPlaybackPreparationKind
+}
+
+struct TVPlexIdentityEnvelope: Decodable, Sendable {
+ let mediaContainer: TVPlexServerIdentity
+
+ private enum CodingKeys: String, CodingKey {
+ case mediaContainer = "MediaContainer"
+ }
+}
+
+struct TVPlexLibrary: Decodable, Hashable, Identifiable, Sendable {
+ let id: String
+ let title: String
+ let type: String
+ let thumb: String?
+ let art: String?
+ let composite: String?
+ var recentArtworkPath: String?
+
+ var artworkPath: String? { composite ?? recentArtworkPath ?? art ?? thumb }
+
+ private enum CodingKeys: String, CodingKey {
+ case key
+ case title
+ case type
+ case thumb
+ case art
+ case composite
+ }
+
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ id = values.decodePlexStringIfPresent(forKey: .key) ?? UUID().uuidString
+ title = try values.decodeIfPresent(String.self, forKey: .title) ?? "Library"
+ type = try values.decodeIfPresent(String.self, forKey: .type) ?? "unknown"
+ thumb = try values.decodeIfPresent(String.self, forKey: .thumb)?.nilIfBlank
+ art = try values.decodeIfPresent(String.self, forKey: .art)?.nilIfBlank
+ composite = try values.decodeIfPresent(String.self, forKey: .composite)?.nilIfBlank
+ }
+}
+
+struct TVPlexPlaybackRequest: Identifiable, Sendable {
+ let item: PlexMediaItem
+ let queue: PlexPlaybackQueue?
+ let source: PlexPlaybackSource?
+ let queueSourcePreference: PlexPlaybackQueueSourcePreference?
+ let startTime: TimeInterval
+ let autoplay: Bool
+ let playbackRate: PlexPlaybackRate
+ let videoQualityOverride: PlexVideoQuality?
+ let forceVideoTranscode: Bool
+ let id: UUID
+ let sessionIdentifier: String
+
+ init(
+ item: PlexMediaItem,
+ queue: PlexPlaybackQueue? = nil,
+ source: PlexPlaybackSource? = nil,
+ queueSourcePreference: PlexPlaybackQueueSourcePreference? = nil,
+ startTime: TimeInterval,
+ autoplay: Bool = true,
+ playbackRate: PlexPlaybackRate = .normal,
+ videoQualityOverride: PlexVideoQuality? = nil,
+ forceVideoTranscode: Bool = false
+ ) {
+ let id = UUID()
+ self.init(
+ item: item,
+ queue: queue,
+ source: source,
+ queueSourcePreference: queueSourcePreference,
+ startTime: startTime,
+ autoplay: autoplay,
+ playbackRate: playbackRate,
+ videoQualityOverride: videoQualityOverride,
+ forceVideoTranscode: forceVideoTranscode,
+ id: id,
+ sessionIdentifier: id.uuidString.lowercased()
+ )
+ }
+
+ private init(
+ item: PlexMediaItem,
+ queue: PlexPlaybackQueue?,
+ source: PlexPlaybackSource?,
+ queueSourcePreference: PlexPlaybackQueueSourcePreference?,
+ startTime: TimeInterval,
+ autoplay: Bool,
+ playbackRate: PlexPlaybackRate,
+ videoQualityOverride: PlexVideoQuality?,
+ forceVideoTranscode: Bool,
+ id: UUID,
+ sessionIdentifier: String
+ ) {
+ self.item = item
+ self.queue = queue
+ self.source = source
+ self.queueSourcePreference = queueSourcePreference
+ self.startTime = startTime
+ self.autoplay = autoplay
+ self.playbackRate = playbackRate
+ self.videoQualityOverride = videoQualityOverride
+ self.forceVideoTranscode = forceVideoTranscode
+ self.id = id
+ self.sessionIdentifier = sessionIdentifier
+ }
+
+ func startingNewPlaybackSession(
+ at startTime: TimeInterval,
+ playbackRate: PlexPlaybackRate
+ ) -> Self {
+ Self(
+ item: item,
+ queue: queue,
+ source: source,
+ queueSourcePreference: queueSourcePreference,
+ startTime: startTime,
+ autoplay: autoplay,
+ playbackRate: playbackRate,
+ videoQualityOverride: videoQualityOverride,
+ forceVideoTranscode: forceVideoTranscode,
+ id: id,
+ sessionIdentifier: UUID().uuidString.lowercased()
+ )
+ }
+
+ func withPlaybackRate(_ playbackRate: PlexPlaybackRate) -> Self {
+ Self(
+ item: item,
+ queue: queue,
+ source: source,
+ queueSourcePreference: queueSourcePreference,
+ startTime: startTime,
+ autoplay: autoplay,
+ playbackRate: playbackRate,
+ videoQualityOverride: videoQualityOverride,
+ forceVideoTranscode: forceVideoTranscode,
+ id: id,
+ sessionIdentifier: sessionIdentifier
+ )
+ }
+
+ func withQueue(_ queue: PlexPlaybackQueue) -> Self {
+ Self(
+ item: item,
+ queue: queue,
+ source: source,
+ queueSourcePreference: queueSourcePreference,
+ startTime: startTime,
+ autoplay: autoplay,
+ playbackRate: playbackRate,
+ videoQualityOverride: videoQualityOverride,
+ forceVideoTranscode: forceVideoTranscode,
+ id: id,
+ sessionIdentifier: sessionIdentifier
+ )
+ }
+}
+
+struct TVPlexLibrariesEnvelope: Decodable, Sendable {
+ let mediaContainer: Container
+
+ struct Container: Decodable, Sendable {
+ let directories: [TVPlexLibrary]
+
+ private enum CodingKeys: String, CodingKey {
+ case directories = "Directory"
+ }
+
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ directories = try values.decodeIfPresent([TVPlexLibrary].self, forKey: .directories) ?? []
+ }
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case mediaContainer = "MediaContainer"
+ }
+}
+
+
+enum TVPlexError: LocalizedError, Sendable {
+ case notConnected
+ case invalidServerURL
+ case invalidResponse
+ case badStatus(Int)
+ case decodingFailed
+ case noPlayableMedia
+ case playbackRejected(String)
+ case serverIdentityMismatch(expected: String, actual: String)
+
+ var errorDescription: String? {
+ switch self {
+ case .notConnected: "Connect to a Plex server before loading this content."
+ case .invalidServerURL: "Plex returned an invalid connection URL."
+ case .invalidResponse: "The Plex server returned an invalid response."
+ case .badStatus(let status): "The Plex server returned HTTP \(status)."
+ case .decodingFailed: "Plex returned media data this version could not read."
+ case .noPlayableMedia: "This item has no playable media."
+ case .playbackRejected(let reason): "Plex rejected playback: \(reason)"
+ case .serverIdentityMismatch(let expected, let actual):
+ "PlexBar expected server \(expected), but the connection reported \(actual)."
+ }
+ }
+}
diff --git a/PlexBar/TV/Models/TVPlexPersonDetails.swift b/PlexBar/TV/Models/TVPlexPersonDetails.swift
new file mode 100644
index 0000000..d65dbd9
--- /dev/null
+++ b/PlexBar/TV/Models/TVPlexPersonDetails.swift
@@ -0,0 +1,7 @@
+import PlexModels
+import Foundation
+
+struct TVPlexPersonDetails: Sendable {
+ let person: PlexTag
+ let media: [PlexMediaItem]
+}
diff --git a/PlexBar/TV/Models/TVTopShelfSelection.swift b/PlexBar/TV/Models/TVTopShelfSelection.swift
new file mode 100644
index 0000000..972e67c
--- /dev/null
+++ b/PlexBar/TV/Models/TVTopShelfSelection.swift
@@ -0,0 +1,70 @@
+import PlexModels
+import Foundation
+
+struct TVTopShelfSelection: Sendable {
+ struct Section: Sendable {
+ let identifier: String
+ let title: String
+ let items: [PlexMediaItem]
+ }
+
+ let sections: [Section]
+
+ init(hubs: [PlexHub]) {
+ // Preserve the server's library order, with Continue Watching first.
+ // /hubs/promoted may return either home hubs or promoted per-library hubs.
+ let selectedHubs = hubs.filter(\.isContinueWatching)
+ + hubs.filter { Self.isRecentlyAdded($0.hubIdentifier) }
+ var seen: Set = []
+ var remainingItems = 40
+ sections = selectedHubs.compactMap { hub in
+ guard remainingItems > 0 else { return nil }
+ var items: [PlexMediaItem] = []
+ for item in hub.metadata {
+ guard TVTopShelfRoute.isValidRatingKey(item.ratingKey),
+ ["movie", "show", "season", "episode", "album", "track"].contains(item.type?.lowercased() ?? ""),
+ Self.artworkPath(for: item) != nil,
+ seen.insert(item.ratingKey).inserted else { continue }
+ items.append(item)
+ remainingItems -= 1
+ if items.count == 10 || remainingItems == 0 { break }
+ }
+ guard !items.isEmpty else { return nil }
+ return Section(identifier: hub.hubIdentifier, title: hub.title, items: items)
+ }
+ }
+
+ private static func isRecentlyAdded(_ identifier: String) -> Bool {
+ let identifier = identifier.lowercased()
+ if ["home.movies.recent", "home.television.recent", "home.music.recent"].contains(identifier) { return true }
+ let parts = identifier.split(separator: ".", omittingEmptySubsequences: false)
+ if parts.count == 4, parts[0] == "music", parts[1] == "recent", parts[2] == "added" {
+ return TVTopShelfRoute.isValidRatingKey(String(parts[3]))
+ }
+ guard parts.count == 3,
+ ["movie", "tv", "music"].contains(parts[0]),
+ parts[1] == "recentlyadded",
+ TVTopShelfRoute.isValidRatingKey(String(parts[2])) else { return false }
+ return true
+ }
+
+ static func artworkPath(for item: PlexMediaItem) -> String? {
+ item.posterArtworkPath
+ }
+
+ static func shape(for item: PlexMediaItem) -> TVTopShelfSnapshot.Item.Shape {
+ ["album", "track"].contains(item.type?.lowercased() ?? "") ? .square : .poster
+ }
+
+ static func title(for item: PlexMediaItem) -> String {
+ if item.type?.lowercased() == "episode" {
+ return [item.grandparentTitle, item.tvEpisodeTitle].compactMap { $0?.nilIfBlank }.joined(separator: " — ")
+ }
+ return item.title
+ }
+
+ static func progress(for item: PlexMediaItem) -> Double {
+ guard let duration = item.duration, duration > 0, let offset = item.viewOffset else { return 0 }
+ return min(max(Double(offset) / Double(duration), 0), 1)
+ }
+}
diff --git a/PlexBar/TV/Playback/TVAudioPlayerStage.swift b/PlexBar/TV/Playback/TVAudioPlayerStage.swift
new file mode 100644
index 0000000..d64a101
--- /dev/null
+++ b/PlexBar/TV/Playback/TVAudioPlayerStage.swift
@@ -0,0 +1,153 @@
+import SwiftUI
+import UIKit
+
+struct TVAudioPlayerStageContainer: View {
+ let store: TVAppStore
+ let presentation: PlexAudioPlaybackPresentation
+
+ var body: some View {
+ TVAudioPlayerStage(presentation: presentation)
+ .environment(store)
+ .allowsHitTesting(false)
+ }
+}
+
+@MainActor
+final class TVAudioPlayerStageHost {
+ private var hostingController: UIHostingController?
+ private var presentation: PlexAudioPlaybackPresentation?
+
+ func update(
+ in overlayView: UIView?,
+ store: TVAppStore,
+ presentation: PlexAudioPlaybackPresentation?
+ ) {
+ guard let overlayView, let presentation else {
+ remove()
+ return
+ }
+
+ let hostingController: UIHostingController
+ if let existingHostingController = self.hostingController {
+ hostingController = existingHostingController
+ if presentation != self.presentation {
+ hostingController.rootView = TVAudioPlayerStageContainer(
+ store: store,
+ presentation: presentation
+ )
+ }
+ } else {
+ hostingController = UIHostingController(
+ rootView: TVAudioPlayerStageContainer(
+ store: store,
+ presentation: presentation
+ )
+ )
+ hostingController.view.backgroundColor = .clear
+ hostingController.view.isUserInteractionEnabled = false
+ hostingController.view.translatesAutoresizingMaskIntoConstraints = false
+ self.hostingController = hostingController
+ }
+ self.presentation = presentation
+
+ guard hostingController.view.superview !== overlayView else { return }
+
+ hostingController.view.removeFromSuperview()
+ overlayView.addSubview(hostingController.view)
+ NSLayoutConstraint.activate([
+ hostingController.view.leadingAnchor.constraint(equalTo: overlayView.leadingAnchor),
+ hostingController.view.trailingAnchor.constraint(equalTo: overlayView.trailingAnchor),
+ hostingController.view.topAnchor.constraint(equalTo: overlayView.topAnchor),
+ hostingController.view.bottomAnchor.constraint(equalTo: overlayView.bottomAnchor),
+ ])
+ }
+
+ func remove() {
+ hostingController?.view.removeFromSuperview()
+ hostingController = nil
+ presentation = nil
+ }
+}
+
+private struct TVAudioPlayerStage: View {
+ let presentation: PlexAudioPlaybackPresentation
+
+ var body: some View {
+ ZStack {
+ backdrop
+
+ HStack(spacing: 60) {
+ artwork
+
+ VStack(alignment: .leading, spacing: 16) {
+ Text("NOW PLAYING")
+ .font(TVTypography.cardTitle)
+ .tracking(2.4)
+ .foregroundStyle(TVTheme.plexGold)
+
+ Text(presentation.title)
+ .font(TVTypography.title)
+ .lineLimit(3)
+
+ ForEach(presentation.metadataLines, id: \.self) { line in
+ Text(line)
+ .font(TVTypography.sectionTitle)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .safeAreaPadding(.horizontal)
+ .safeAreaPadding(.bottom, 160)
+ }
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel(accessibilityLabel)
+ }
+
+ private var artworkPath: String? {
+ presentation.artworkPaths.first
+ }
+
+ private var backdrop: some View {
+ TVPlexArtwork(
+ path: artworkPath,
+ width: 1_920,
+ height: 1_080,
+ systemImage: "music.note"
+ )
+ .blur(radius: 72)
+ .scaleEffect(1.18)
+ .overlay {
+ LinearGradient(
+ colors: [
+ .black.opacity(0.28),
+ .black.opacity(0.72),
+ .black.opacity(0.94),
+ ],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+ }
+ .clipped()
+ .ignoresSafeArea()
+ .accessibilityHidden(true)
+ }
+
+ private var artwork: some View {
+ TVPlexArtwork(
+ path: artworkPath,
+ width: 900,
+ height: 900,
+ systemImage: "music.note"
+ )
+ .aspectRatio(1, contentMode: .fit)
+ .containerRelativeFrame(.horizontal, count: 4, spacing: TVLayout.cardSpacing)
+ .clipShape(.rect(cornerRadius: 12))
+ .accessibilityHidden(true)
+ }
+
+ private var accessibilityLabel: String {
+ ([presentation.title] + presentation.metadataLines).joined(separator: ", ")
+ }
+}
diff --git a/PlexBar/TV/Playback/TVPlaybackInfoView.swift b/PlexBar/TV/Playback/TVPlaybackInfoView.swift
new file mode 100644
index 0000000..cbb6f69
--- /dev/null
+++ b/PlexBar/TV/Playback/TVPlaybackInfoView.swift
@@ -0,0 +1,107 @@
+import SwiftUI
+
+struct TVPlaybackInfoView: View {
+ let presentation: PlexPlaybackInfoPresentation
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 30) {
+ header
+ Divider()
+ sections
+ }
+ .safeAreaPadding()
+ }
+ .accessibilityElement(children: .contain)
+ .accessibilityLabel("Playback Info")
+ }
+
+ private var header: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text(presentation.item.title)
+ .font(TVTypography.sectionTitle)
+ .lineLimit(2)
+
+ if let hierarchyLine = presentation.item.hierarchyLine {
+ Text(hierarchyLine)
+ .font(TVTypography.sectionTitle)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .accessibilityElement(children: .combine)
+ }
+
+ private var sections: some View {
+ LazyVGrid(
+ columns: Array(
+ repeating: GridItem(.flexible(), spacing: 48, alignment: .top),
+ count: 3
+ ),
+ alignment: .leading,
+ spacing: 32
+ ) {
+ TVPlaybackInfoSection(
+ title: "Playback",
+ systemImage: "play.circle.fill",
+ rows: presentation.playbackRows
+ )
+
+ if !presentation.videoRows.isEmpty {
+ TVPlaybackInfoSection(
+ title: "Delivered Video",
+ systemImage: "film.fill",
+ rows: presentation.videoRows
+ )
+ }
+
+ if !presentation.audioRows.isEmpty {
+ TVPlaybackInfoSection(
+ title: "Delivered Audio",
+ systemImage: "waveform",
+ rows: presentation.audioRows
+ )
+ }
+
+ if !presentation.performanceRows.isEmpty {
+ TVPlaybackInfoSection(
+ title: "Performance",
+ systemImage: "gauge.with.dots.needle.50percent",
+ rows: presentation.performanceRows
+ )
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .topLeading)
+ }
+}
+
+private struct TVPlaybackInfoSection: View {
+ let title: String
+ let systemImage: String
+ let rows: [PlexPlaybackInfoPresentation.Row]
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 18) {
+ Label(title, systemImage: systemImage)
+ .font(TVTypography.sectionTitle)
+
+ VStack(alignment: .leading, spacing: 14) {
+ ForEach(rows) { row in
+ HStack(alignment: .firstTextBaseline, spacing: 24) {
+ Text(row.label)
+ .foregroundStyle(.secondary)
+ Text(row.value)
+ .fontWeight(.semibold)
+ .frame(maxWidth: .infinity, alignment: .trailing)
+ }
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(row.label)
+ .accessibilityValue(row.value)
+ }
+ }
+ .font(TVTypography.body)
+ }
+ .frame(maxWidth: .infinity, alignment: .topLeading)
+ }
+}
diff --git a/PlexBar/TV/Playback/TVPlaybackQueueView.swift b/PlexBar/TV/Playback/TVPlaybackQueueView.swift
new file mode 100644
index 0000000..d397a21
--- /dev/null
+++ b/PlexBar/TV/Playback/TVPlaybackQueueView.swift
@@ -0,0 +1,249 @@
+import PlexModels
+import SwiftUI
+
+struct TVPlaybackQueueHostView: View {
+ let store: TVAppStore
+ let presentation: PlexPlaybackQueuePresentation
+ let canSelectItems: Bool
+ let play: (String) -> Void
+ let move: (String, PlexPlayQueueItemMoveDirection) -> Void
+ let remove: (String) -> Void
+
+ var body: some View {
+ TVPlaybackQueueView(
+ presentation: presentation,
+ canSelectItems: canSelectItems,
+ play: play,
+ move: move,
+ remove: remove
+ )
+ .environment(store)
+ }
+}
+
+private struct TVPlaybackQueueView: View {
+ let presentation: PlexPlaybackQueuePresentation
+ let canSelectItems: Bool
+ let play: (String) -> Void
+ let move: (String, PlexPlayQueueItemMoveDirection) -> Void
+ let remove: (String) -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 24) {
+ HStack(alignment: .firstTextBaseline) {
+ Text("Up Next")
+ .font(TVTypography.title)
+
+ Spacer()
+
+ Text(positionLabel)
+ .font(TVTypography.cardTitle)
+ .foregroundStyle(.secondary)
+ }
+ .safeAreaPadding(.horizontal)
+
+ ScrollView(.horizontal) {
+ LazyHStack(alignment: .top, spacing: TVLayout.cardSpacing) {
+ TVPlaybackQueueCurrentCard(item: presentation.currentItem)
+
+ ForEach(presentation.upcomingItems) { item in
+ TVPlaybackQueueButton(
+ item: item,
+ presentation: presentation,
+ canSelectItems: canSelectItems,
+ play: play,
+ move: move,
+ remove: remove
+ )
+ }
+
+ if presentation.unloadedRemainingCount > 0 {
+ TVPlaybackQueueRemainingCard(
+ count: presentation.unloadedRemainingCount
+ )
+ }
+ }
+ .safeAreaPadding(.horizontal)
+ .padding(.vertical, 16)
+ }
+ .scrollClipDisabled()
+ .buttonStyle(.borderless)
+ }
+ .safeAreaPadding(.vertical)
+ .accessibilityElement(children: .contain)
+ .accessibilityLabel("Up Next")
+ }
+
+ private var positionLabel: String {
+ "\(presentation.currentPosition) of \(presentation.totalCount)"
+ }
+}
+
+private struct TVPlaybackQueueCurrentCard: View {
+ let item: PlexMediaItem
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ TVPlaybackQueueArtwork(item: item)
+ .overlay(alignment: .bottomLeading) {
+ Label("Now Playing", systemImage: "waveform")
+ .font(TVTypography.caption)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+ .background(.black.opacity(0.72), in: Capsule())
+ .padding(12)
+ }
+
+ TVPlaybackQueueLabels(item: item)
+ }
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel("Now Playing, \(item.title)")
+ }
+}
+
+private struct TVPlaybackQueueButton: View {
+ let item: PlexMediaItem
+ let presentation: PlexPlaybackQueuePresentation
+ let canSelectItems: Bool
+ let play: (String) -> Void
+ let move: (String, PlexPlayQueueItemMoveDirection) -> Void
+ let remove: (String) -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ Button(action: playNow) {
+ TVPlaybackQueueArtwork(item: item)
+ .hoverEffect(.highlight)
+ }
+ .buttonStyle(.borderless)
+ .disabled(!canPlay)
+ .accessibilityLabel(item.title)
+ .accessibilityValue(item.contextTitle ?? "")
+ .accessibilityHint("Play this item now")
+ .contextMenu {
+ Button("Play Now", systemImage: "play.fill", action: playNow)
+ .disabled(!canPlay)
+
+ Divider()
+
+ Button("Move Earlier", systemImage: "arrow.left", action: moveEarlier)
+ .disabled(!canMoveEarlier)
+
+ Button("Move Later", systemImage: "arrow.right", action: moveLater)
+ .disabled(!canMoveLater)
+
+ Divider()
+
+ Button(
+ "Remove from Up Next",
+ systemImage: "minus.circle",
+ role: .destructive,
+ action: removeFromQueue
+ )
+ .disabled(!canRemove)
+ }
+
+ TVMediaCardLabels(
+ title: item.title,
+ subtitle: item.contextTitle
+ )
+ .accessibilityHidden(true)
+ }
+ }
+
+ private var playQueueItemID: String? {
+ item.playQueueItemID?.nilIfBlank
+ }
+
+ private var canPlay: Bool {
+ canSelectItems && playQueueItemID != nil
+ }
+
+ private var canMoveEarlier: Bool {
+ guard canSelectItems, let playQueueItemID else { return false }
+ return presentation.canMoveUpcomingItem(
+ playQueueItemID: playQueueItemID,
+ direction: .up
+ )
+ }
+
+ private var canMoveLater: Bool {
+ guard canSelectItems, let playQueueItemID else { return false }
+ return presentation.canMoveUpcomingItem(
+ playQueueItemID: playQueueItemID,
+ direction: .down
+ )
+ }
+
+ private var canRemove: Bool {
+ canSelectItems
+ && presentation.canRemoveUpcomingItems
+ && playQueueItemID != nil
+ }
+
+ private func playNow() {
+ guard let playQueueItemID else { return }
+ play(playQueueItemID)
+ }
+
+ private func moveEarlier() {
+ guard let playQueueItemID else { return }
+ move(playQueueItemID, .up)
+ }
+
+ private func moveLater() {
+ guard let playQueueItemID else { return }
+ move(playQueueItemID, .down)
+ }
+
+ private func removeFromQueue() {
+ guard let playQueueItemID else { return }
+ remove(playQueueItemID)
+ }
+}
+
+private struct TVPlaybackQueueArtwork: View {
+ let item: PlexMediaItem
+
+ var body: some View {
+ TVPlexArtwork(
+ path: item.preferredBackdropPath,
+ width: 660,
+ height: 372,
+ systemImage: item.type?.lowercased() == "track" ? "music.note" : "film"
+ )
+ .aspectRatio(16.0 / 9.0, contentMode: .fit)
+ .containerRelativeFrame(.horizontal, count: 4, spacing: TVLayout.cardSpacing)
+ .clipShape(.rect(cornerRadius: 12))
+ }
+}
+
+private struct TVPlaybackQueueLabels: View {
+ let item: PlexMediaItem
+
+ var body: some View {
+ TVMediaCardLabels(
+ title: item.title,
+ subtitle: item.contextTitle
+ )
+ }
+}
+
+private struct TVPlaybackQueueRemainingCard: View {
+ let count: Int
+
+ var body: some View {
+ VStack(spacing: 16) {
+ Image(systemName: "ellipsis")
+ .font(.title)
+ Text("\(count) more on this server")
+ .font(TVTypography.cardTitle)
+ .multilineTextAlignment(.center)
+ }
+ .foregroundStyle(.secondary)
+ .containerRelativeFrame(.horizontal, count: 4, spacing: TVLayout.cardSpacing)
+ .aspectRatio(16.0 / 9.0, contentMode: .fit)
+ .background(.quaternary, in: .rect(cornerRadius: 12))
+ .accessibilityElement(children: .combine)
+ }
+}
diff --git a/PlexBar/TV/Playback/TVPlayerView.swift b/PlexBar/TV/Playback/TVPlayerView.swift
new file mode 100644
index 0000000..c622f04
--- /dev/null
+++ b/PlexBar/TV/Playback/TVPlayerView.swift
@@ -0,0 +1,5075 @@
+import PlexModels
+import AVFoundation
+import AVKit
+@preconcurrency import MediaPlayer
+import Observation
+import SwiftUI
+import UIKit
+
+struct TVPlayerView: View {
+ @Environment(TVAppStore.self) private var store
+ @Environment(\.scenePhase) private var scenePhase
+ @Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion
+ let request: TVPlexPlaybackRequest
+
+ @State private var session = TVPlaybackSession()
+
+ var body: some View {
+ ZStack {
+ Color.black.ignoresSafeArea()
+
+ if let player = session.player {
+ TVNativePlayerController(
+ player: player,
+ store: store,
+ mediaKind: session.playbackMediaKind,
+ selectedVideoQuality: session.selectedVideoQuality
+ ?? store.activeVideoQuality,
+ selectedMusicQuality: session.selectedMusicQuality
+ ?? store.activeMusicQuality,
+ selectedAudioBoost: session.selectedAudioBoost
+ ?? store.audioBoost,
+ selectedSubtitleSize: store.subtitleSize,
+ automaticallySyncsSubtitles: store.automaticallySyncSubtitles,
+ automaticallyAdjustsVideoQuality: store.automaticallyAdjustVideoQuality,
+ videoConversionControl: videoConversionControl,
+ selectedVideoScalingMode: store.videoScalingMode,
+ showsVideoQualityMenu: session.isVideoPlayback,
+ showsMusicQualityMenu: session.isMusicPlayback
+ && store.connection?.kind != .local,
+ showsAudioBoostMenu: session.supportsAudioBoost,
+ showsSubtitleSizeMenu: session.hasAvailableSubtitles,
+ showsSubtitleAutoSyncAction: session.supportsSubtitleAutoSync,
+ subtitleOffsetSelection: session.subtitleOffsetSelection,
+ playbackVersionSelection: session.playbackVersionSelection,
+ mediaSelection: session.serverManagedMediaSelection,
+ audioPresentation: session.audioPresentation,
+ canChangeMediaSelection: !session.isReconfiguringMediaSelection
+ && !session.isNavigatingQueue
+ && !session.isMutatingQueue
+ && !session.isReplacingPlayback
+ && !session.isPreparingInitialPosition,
+ playbackInfo: session.playbackInfo,
+ queuePresentation: session.queuePresentation,
+ markerAction: session.activeMarkerAction,
+ availableMarkerKinds: session.availableMarkerKinds,
+ markerPreferences: store.playbackMarkerPreferences,
+ canGoPrevious: session.canGoPrevious,
+ canGoNext: session.canGoNext,
+ previousItemTitle: session.previousItemTitle,
+ nextItemTitle: session.nextItemTitle,
+ canChangeShuffle: session.canChangeShuffle,
+ isShuffled: session.isShuffled,
+ repeatMode: session.repeatMode,
+ canRepeatAll: session.canRepeatAll,
+ sleepTimer: store.playbackSleepTimer,
+ selectVideoQuality: session.selectVideoQuality,
+ selectMusicQuality: session.selectMusicQuality,
+ selectAudioBoost: session.selectAudioBoost,
+ selectSubtitleSize: session.selectSubtitleSize,
+ setSubtitleAutoSync: session.setSubtitleAutoSync,
+ setSubtitleOffset: session.setSubtitleOffset,
+ setVideoConversionForced: session.setVideoConversionForced,
+ selectPlaybackVersion: session.selectPlaybackVersion,
+ selectVideoScalingMode: store.selectVideoScalingMode,
+ selectAudioStream: session.selectAudioStream,
+ selectSubtitleStream: session.selectSubtitleStream,
+ restartFromBeginning: session.restartFromBeginning,
+ playPreviousItem: session.playPreviousItem,
+ playNextItem: session.playNextItem,
+ playQueuedItem: session.playQueuedItem,
+ moveQueuedItem: session.moveQueuedItem,
+ removeQueuedItem: session.removeQueuedItem,
+ setShuffled: session.setShuffled,
+ setRepeatMode: session.setRepeatMode,
+ setSleepTimer: { preset in
+ session.setSleepTimer(preset, store: store)
+ },
+ setMarkerBehavior: store.setPlaybackMarkerBehavior,
+ skipMarker: session.skipActiveMarker,
+ shouldPresentContentProposal: session.shouldPresentContentProposal,
+ acceptContentProposal: session.acceptContentProposal,
+ rejectContentProposal: session.rejectContentProposal,
+ shouldHandlePlayPausePress: session.shouldHandleNativePlayPausePress,
+ handlePlayPausePress: session.handleNativePlayPausePress,
+ recordInteraction: session.recordUserInteraction
+ )
+ .ignoresSafeArea()
+
+ if let nextItem = session.upNextItem {
+ TVPostPlayOverlay(
+ item: nextItem,
+ title: session.postPlayTitle,
+ countdown: session.postPlayCountdown,
+ playNow: session.playNextNow,
+ cancel: session.cancelPostPlay
+ )
+ .transition(.opacity)
+ }
+ } else if let errorMessage = session.errorMessage {
+ ContentUnavailableView {
+ Label("Couldn’t Play", systemImage: "exclamationmark.triangle.fill")
+ } description: {
+ Text(errorMessage)
+ } actions: {
+ if session.canRetryPlayback {
+ Button("Try Again", systemImage: "arrow.clockwise") {
+ Task { await session.retry(request: request, store: store) }
+ }
+ }
+ Button("Close", role: .cancel, action: store.dismissPlayer)
+ }
+ } else {
+ VStack(spacing: 26) {
+ ProgressView()
+ .controlSize(.extraLarge)
+ Text("Preparing \(request.item.title)…")
+ .font(.title2)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ .task(id: request.id) {
+ await session.prepare(request: request, store: store)
+ }
+ .animation(
+ PlexMotion.surfaceAnimation(reduceMotion: accessibilityReduceMotion),
+ value: session.upNextItem?.id
+ )
+ .alert("Couldn’t Change Playback", isPresented: Binding(
+ get: { session.mediaSelectionErrorMessage != nil },
+ set: { if !$0 { session.dismissMediaSelectionError() } }
+ )) {
+ Button("OK", action: session.dismissMediaSelectionError)
+ } message: {
+ Text(session.mediaSelectionErrorMessage ?? "Plex could not change this playback setting.")
+ }
+ .alert(
+ "Change Video Quality?",
+ isPresented: Binding(
+ get: { session.qualitySuggestion != nil },
+ set: { isPresented in
+ if !isPresented {
+ session.dismissQualitySuggestion()
+ }
+ }
+ ),
+ presenting: session.qualitySuggestion
+ ) { suggestion in
+ Button("Change to \(suggestion.targetQuality.label)") {
+ session.acceptQualitySuggestion()
+ }
+ Button("Keep Current", role: .cancel) {
+ session.dismissQualitySuggestion()
+ }
+ } message: { suggestion in
+ Text(suggestion.message)
+ }
+ .alert("Couldn’t Update Playback", isPresented: Binding(
+ get: { session.queueNavigationErrorMessage != nil },
+ set: { if !$0 { session.dismissQueueNavigationError() } }
+ )) {
+ if session.canRetryQueueOperation {
+ Button("Try Again", systemImage: "arrow.clockwise") {
+ session.retryQueueOperation()
+ }
+ }
+ Button("Dismiss", role: .cancel, action: session.dismissQueueNavigationError)
+ } message: {
+ Text(session.queueNavigationErrorMessage ?? "Plex could not update this playback session.")
+ }
+ .onDisappear {
+ session.stop(store: store, request: request)
+ }
+ .onChange(of: scenePhase) { _, newPhase in
+ session.scenePhaseDidChange(newPhase)
+ }
+ .onChange(of: store.playbackMarkerPreferences) { _, preferences in
+ session.playbackMarkerPreferencesDidChange(preferences, store: store)
+ }
+ }
+
+ private var videoConversionControl: TVVideoConversionControl? {
+ guard session.isVideoPlayback else { return nil }
+ if request.forceVideoTranscode {
+ return TVVideoConversionControl(isForcingConversion: true)
+ }
+ guard store.automaticallyAdjustVideoQuality,
+ let playbackMethod = session.playbackMethod,
+ playbackMethod != .transcode else {
+ return nil
+ }
+ return TVVideoConversionControl(isForcingConversion: false)
+ }
+}
+
+private struct TVVideoConversionControl: Equatable {
+ let isForcingConversion: Bool
+
+ var title: String {
+ isForcingConversion ? "Play Original Quality" : "Convert Automatically"
+ }
+
+ var systemImage: String {
+ isForcingConversion ? "play.rectangle.fill" : "arrow.triangle.2.circlepath"
+ }
+
+ var nextValue: Bool { !isForcingConversion }
+}
+
+private enum PlexNativeSubtitleStyle {
+ static func apply(to playerItem: AVPlayerItem?, size: PlexSubtitleSize) {
+ guard let rule = AVTextStyleRule(textMarkupAttributes: [
+ kCMTextMarkupAttribute_RelativeFontSize as String: NSNumber(value: size.rawValue),
+ ]) else {
+ playerItem?.textStyleRules = nil
+ return
+ }
+ playerItem?.textStyleRules = [rule]
+ }
+}
+
+private struct TVNativePlayerController: UIViewControllerRepresentable {
+ let player: AVPlayer
+ let store: TVAppStore
+ let mediaKind: PlexPlaybackMediaKind
+ let selectedVideoQuality: PlexVideoQuality
+ let selectedMusicQuality: PlexMusicQuality
+ let selectedAudioBoost: PlexAudioBoost
+ let selectedSubtitleSize: PlexSubtitleSize
+ let automaticallySyncsSubtitles: Bool
+ let automaticallyAdjustsVideoQuality: Bool
+ let videoConversionControl: TVVideoConversionControl?
+ let selectedVideoScalingMode: PlexVideoScalingMode
+ let showsVideoQualityMenu: Bool
+ let showsMusicQualityMenu: Bool
+ let showsAudioBoostMenu: Bool
+ let showsSubtitleSizeMenu: Bool
+ let showsSubtitleAutoSyncAction: Bool
+ let subtitleOffsetSelection: PlexSubtitleOffsetSelection?
+ let playbackVersionSelection: PlexPlaybackVersionSelection?
+ let mediaSelection: PlexServerManagedMediaSelection
+ let audioPresentation: PlexAudioPlaybackPresentation?
+ let canChangeMediaSelection: Bool
+ let playbackInfo: PlexPlaybackInfoPresentation?
+ let queuePresentation: PlexPlaybackQueuePresentation?
+ let markerAction: PlexPlaybackMarkerAction?
+ let availableMarkerKinds: [PlexPlaybackMarkerKind]
+ let markerPreferences: PlexPlaybackMarkerPreferences
+ let canGoPrevious: Bool
+ let canGoNext: Bool
+ let previousItemTitle: String?
+ let nextItemTitle: String?
+ let canChangeShuffle: Bool
+ let isShuffled: Bool
+ let repeatMode: PlexPlaybackRepeatMode
+ let canRepeatAll: Bool
+ let sleepTimer: PlexPlaybackSleepTimer
+ let selectVideoQuality: (PlexVideoQuality) -> Void
+ let selectMusicQuality: (PlexMusicQuality) -> Void
+ let selectAudioBoost: (PlexAudioBoost) -> Void
+ let selectSubtitleSize: (PlexSubtitleSize) -> Void
+ let setSubtitleAutoSync: (Bool) -> Void
+ let setSubtitleOffset: (Int) -> Void
+ let setVideoConversionForced: (Bool) -> Void
+ let selectPlaybackVersion: (Int) -> Void
+ let selectVideoScalingMode: (PlexVideoScalingMode) -> Void
+ let selectAudioStream: (Int) -> Void
+ let selectSubtitleStream: (Int?) -> Void
+ let restartFromBeginning: () -> Void
+ let playPreviousItem: () -> Void
+ let playNextItem: () -> Void
+ let playQueuedItem: (String) -> Void
+ let moveQueuedItem: (String, PlexPlayQueueItemMoveDirection) -> Void
+ let removeQueuedItem: (String) -> Void
+ let setShuffled: (Bool) -> Void
+ let setRepeatMode: (PlexPlaybackRepeatMode) -> Void
+ let setSleepTimer: (PlexPlaybackSleepTimerPreset) -> Void
+ let setMarkerBehavior: (PlexPlaybackMarkerBehavior, PlexPlaybackMarkerKind) -> Void
+ let skipMarker: () -> Void
+ let shouldPresentContentProposal: (AVContentProposal) -> Bool
+ let acceptContentProposal: (AVContentProposal) -> Void
+ let rejectContentProposal: (AVContentProposal) -> Void
+ let shouldHandlePlayPausePress: () -> Bool
+ let handlePlayPausePress: () -> Bool
+ let recordInteraction: () -> Void
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(
+ selectVideoQuality: selectVideoQuality,
+ selectMusicQuality: selectMusicQuality,
+ selectAudioBoost: selectAudioBoost,
+ selectSubtitleSize: selectSubtitleSize,
+ setSubtitleAutoSync: setSubtitleAutoSync,
+ setSubtitleOffset: setSubtitleOffset,
+ setVideoConversionForced: setVideoConversionForced,
+ selectPlaybackVersion: selectPlaybackVersion,
+ selectVideoScalingMode: selectVideoScalingMode,
+ selectAudioStream: selectAudioStream,
+ selectSubtitleStream: selectSubtitleStream,
+ restartFromBeginning: restartFromBeginning,
+ playPreviousItem: playPreviousItem,
+ playNextItem: playNextItem,
+ playQueuedItem: playQueuedItem,
+ moveQueuedItem: moveQueuedItem,
+ removeQueuedItem: removeQueuedItem,
+ setShuffled: setShuffled,
+ setRepeatMode: setRepeatMode,
+ setSleepTimer: setSleepTimer,
+ setMarkerBehavior: setMarkerBehavior,
+ skipMarker: skipMarker,
+ shouldPresentContentProposal: shouldPresentContentProposal,
+ acceptContentProposal: acceptContentProposal,
+ rejectContentProposal: rejectContentProposal,
+ dismissPlayer: store.dismissPlayer,
+ recordInteraction: recordInteraction,
+ shouldHandlePlayPausePress: shouldHandlePlayPausePress,
+ handlePlayPausePress: handlePlayPausePress
+ )
+ }
+
+ func makeUIViewController(context: Context) -> AVPlayerViewController {
+ let controller = AVPlayerViewController()
+ controller.player = player
+ controller.view.accessibilityIdentifier = "native-player"
+ controller.delegate = context.coordinator
+ controller.showsPlaybackControls = true
+ controller.playbackControlsIncludeTransportBar = true
+ controller.playbackControlsIncludeInfoViews = true
+ controller.transportBarIncludesTitleView = true
+ controller.appliesPreferredDisplayCriteriaAutomatically = true
+ controller.allowsPictureInPicturePlayback = audioPresentation == nil
+ context.coordinator.installRemotePressRecognizers(in: controller)
+ configureSkippingBehavior(controller)
+ PlexNativePlaybackSpeedConfiguration.apply(to: controller)
+ PlexNativeVideoScalingConfiguration.apply(
+ to: controller,
+ scalingMode: selectedVideoScalingMode
+ )
+ PlexNativeSubtitleStyle.apply(to: player.currentItem, size: selectedSubtitleSize)
+ configureTransportMenu(controller, coordinator: context.coordinator)
+ configureInfoActions(controller, coordinator: context.coordinator)
+ context.coordinator.updateContextualAction(markerAction, in: controller)
+ context.coordinator.updatePlaybackInfo(playbackInfo, in: controller)
+ context.coordinator.updateAudioStage(
+ audioPresentation,
+ store: store,
+ in: controller
+ )
+ context.coordinator.updatePlaybackQueue(
+ queuePresentation,
+ store: store,
+ canSelectItems: canChangeMediaSelection,
+ in: controller
+ )
+ return controller
+ }
+
+ func updateUIViewController(_ controller: AVPlayerViewController, context: Context) {
+ if controller.player !== player {
+ controller.player = player
+ }
+ let allowsPictureInPicturePlayback = audioPresentation == nil
+ if controller.allowsPictureInPicturePlayback != allowsPictureInPicturePlayback {
+ controller.allowsPictureInPicturePlayback = allowsPictureInPicturePlayback
+ }
+ configureSkippingBehavior(controller)
+ PlexNativePlaybackSpeedConfiguration.apply(to: controller)
+ PlexNativeVideoScalingConfiguration.apply(
+ to: controller,
+ scalingMode: selectedVideoScalingMode
+ )
+ PlexNativeSubtitleStyle.apply(to: player.currentItem, size: selectedSubtitleSize)
+ context.coordinator.selectVideoQuality = selectVideoQuality
+ context.coordinator.selectMusicQuality = selectMusicQuality
+ context.coordinator.selectAudioBoost = selectAudioBoost
+ context.coordinator.selectSubtitleSize = selectSubtitleSize
+ context.coordinator.setSubtitleAutoSync = setSubtitleAutoSync
+ context.coordinator.setSubtitleOffset = setSubtitleOffset
+ context.coordinator.setVideoConversionForced = setVideoConversionForced
+ context.coordinator.selectPlaybackVersion = selectPlaybackVersion
+ context.coordinator.selectVideoScalingMode = selectVideoScalingMode
+ context.coordinator.selectAudioStream = selectAudioStream
+ context.coordinator.selectSubtitleStream = selectSubtitleStream
+ context.coordinator.restartFromBeginning = restartFromBeginning
+ context.coordinator.playPreviousItem = playPreviousItem
+ context.coordinator.playNextItem = playNextItem
+ context.coordinator.playQueuedItem = playQueuedItem
+ context.coordinator.moveQueuedItem = moveQueuedItem
+ context.coordinator.removeQueuedItem = removeQueuedItem
+ context.coordinator.setShuffled = setShuffled
+ context.coordinator.setRepeatMode = setRepeatMode
+ context.coordinator.setSleepTimer = setSleepTimer
+ context.coordinator.setMarkerBehavior = setMarkerBehavior
+ context.coordinator.skipMarker = skipMarker
+ context.coordinator.shouldPresentContentProposal = shouldPresentContentProposal
+ context.coordinator.acceptContentProposal = acceptContentProposal
+ context.coordinator.rejectContentProposal = rejectContentProposal
+ context.coordinator.dismissPlayer = store.dismissPlayer
+ context.coordinator.recordInteraction = recordInteraction
+ context.coordinator.shouldHandlePlayPausePress = shouldHandlePlayPausePress
+ context.coordinator.handlePlayPausePress = handlePlayPausePress
+ configureTransportMenu(controller, coordinator: context.coordinator)
+ configureInfoActions(controller, coordinator: context.coordinator)
+ context.coordinator.updateContextualAction(markerAction, in: controller)
+ context.coordinator.updatePlaybackInfo(playbackInfo, in: controller)
+ context.coordinator.updateAudioStage(
+ audioPresentation,
+ store: store,
+ in: controller
+ )
+ context.coordinator.updatePlaybackQueue(
+ queuePresentation,
+ store: store,
+ canSelectItems: canChangeMediaSelection,
+ in: controller
+ )
+ }
+
+ static func dismantleUIViewController(
+ _ controller: AVPlayerViewController,
+ coordinator: Coordinator
+ ) {
+ coordinator.dismantle(from: controller)
+ controller.player = nil
+ controller.delegate = nil
+ controller.transportBarCustomMenuItems = []
+ controller.infoViewActions = []
+ controller.contextualActions = []
+ controller.customInfoViewControllers = []
+ }
+
+ private func configureSkippingBehavior(_ controller: AVPlayerViewController) {
+ let configuration = PlexNativeSkippingConfiguration(
+ mediaKind: mediaKind,
+ canMovePrevious: canGoPrevious,
+ canMoveNext: canGoNext,
+ controlsEnabled: canChangeMediaSelection
+ )
+ let skippingBehavior: AVPlayerViewControllerSkippingBehavior = switch configuration.mode {
+ case .time: .default
+ case .item: .skipItem
+ }
+ if controller.skippingBehavior != skippingBehavior {
+ controller.skippingBehavior = skippingBehavior
+ }
+
+ if controller.isSkipBackwardEnabled != configuration.isBackwardEnabled {
+ controller.isSkipBackwardEnabled = configuration.isBackwardEnabled
+ }
+ if controller.isSkipForwardEnabled != configuration.isForwardEnabled {
+ controller.isSkipForwardEnabled = configuration.isForwardEnabled
+ }
+ }
+
+ private func configureTransportMenu(
+ _ controller: AVPlayerViewController,
+ coordinator: Coordinator
+ ) {
+ guard coordinator.shouldUpdateTransportMenu(
+ selected: selectedVideoQuality,
+ selectedMusicQuality: selectedMusicQuality,
+ selectedAudioBoost: selectedAudioBoost,
+ selectedSubtitleSize: selectedSubtitleSize,
+ automaticallySyncsSubtitles: automaticallySyncsSubtitles,
+ automaticallyAdjustsVideoQuality: automaticallyAdjustsVideoQuality,
+ videoConversionControl: videoConversionControl,
+ scalingMode: selectedVideoScalingMode,
+ showsVideoQualityMenu: showsVideoQualityMenu,
+ showsMusicQualityMenu: showsMusicQualityMenu,
+ showsAudioBoostMenu: showsAudioBoostMenu,
+ showsSubtitleSizeMenu: showsSubtitleSizeMenu,
+ showsSubtitleAutoSyncAction: showsSubtitleAutoSyncAction,
+ subtitleOffsetSelection: subtitleOffsetSelection,
+ playbackVersionSelection: playbackVersionSelection,
+ mediaSelection: mediaSelection,
+ canChangeMediaSelection: canChangeMediaSelection,
+ canGoPrevious: canGoPrevious,
+ canGoNext: canGoNext,
+ previousItemTitle: previousItemTitle,
+ nextItemTitle: nextItemTitle,
+ canChangeShuffle: canChangeShuffle,
+ isShuffled: isShuffled,
+ repeatMode: repeatMode,
+ canRepeatAll: canRepeatAll,
+ sleepTimer: sleepTimer,
+ availableMarkerKinds: availableMarkerKinds,
+ markerPreferences: markerPreferences
+ ) else {
+ return
+ }
+ var playbackItems: [UIMenuElement] = []
+ if let playbackVersionSelection {
+ playbackItems.append(coordinator.playbackVersionMenu(
+ selection: playbackVersionSelection,
+ canChange: canChangeMediaSelection
+ ))
+ }
+ if showsVideoQualityMenu {
+ playbackItems.append(coordinator.qualityMenu(
+ selected: selectedVideoQuality,
+ automaticallyAdjusts: automaticallyAdjustsVideoQuality,
+ canChange: canChangeMediaSelection
+ ))
+ if let videoConversionControl {
+ playbackItems.append(coordinator.videoConversionAction(
+ control: videoConversionControl,
+ canChange: canChangeMediaSelection
+ ))
+ }
+ playbackItems.append(
+ coordinator.videoScalingMenu(selected: selectedVideoScalingMode)
+ )
+ }
+ if showsMusicQualityMenu {
+ playbackItems.append(coordinator.musicQualityMenu(
+ selected: selectedMusicQuality,
+ canChange: canChangeMediaSelection
+ ))
+ }
+
+ var audioAndSubtitleItems: [UIMenuElement] = []
+ if !mediaSelection.audioOptions.isEmpty {
+ audioAndSubtitleItems.append(coordinator.audioMenu(
+ options: mediaSelection.audioOptions,
+ canChange: canChangeMediaSelection
+ ))
+ }
+ if !mediaSelection.subtitleOptions.isEmpty {
+ audioAndSubtitleItems.append(coordinator.subtitleMenu(
+ options: mediaSelection.subtitleOptions,
+ canChange: canChangeMediaSelection
+ ))
+ }
+ if showsAudioBoostMenu {
+ audioAndSubtitleItems.append(coordinator.audioBoostMenu(
+ selected: selectedAudioBoost,
+ canChange: canChangeMediaSelection
+ ))
+ }
+ if showsSubtitleSizeMenu {
+ audioAndSubtitleItems.append(coordinator.subtitleSizeMenu(
+ selected: selectedSubtitleSize,
+ canChange: canChangeMediaSelection
+ ))
+ }
+ if showsSubtitleAutoSyncAction {
+ audioAndSubtitleItems.append(coordinator.subtitleAutoSyncAction(
+ isEnabled: automaticallySyncsSubtitles,
+ canChange: canChangeMediaSelection
+ ))
+ }
+ if let subtitleOffsetSelection {
+ audioAndSubtitleItems.append(coordinator.subtitleOffsetMenu(
+ selection: subtitleOffsetSelection,
+ canChange: canChangeMediaSelection
+ ))
+ }
+ if !audioAndSubtitleItems.isEmpty {
+ playbackItems.append(coordinator.transportGroupMenu(
+ title: "Audio & Subtitles",
+ systemImage: "captions.bubble.fill",
+ children: audioAndSubtitleItems
+ ))
+ }
+
+ var queueAndTimingItems: [UIMenuElement] = []
+ if canGoPrevious || canGoNext || canChangeShuffle {
+ queueAndTimingItems.append(coordinator.queueMenu(
+ canGoPrevious: canGoPrevious && canChangeMediaSelection,
+ canGoNext: canGoNext && canChangeMediaSelection,
+ previousItemTitle: previousItemTitle,
+ nextItemTitle: nextItemTitle,
+ canChangeShuffle: canChangeShuffle && canChangeMediaSelection,
+ isShuffled: isShuffled
+ ))
+ }
+ queueAndTimingItems.append(coordinator.repeatMenu(
+ selected: repeatMode,
+ canRepeatAll: canRepeatAll,
+ canChange: canChangeMediaSelection
+ ))
+ if !availableMarkerKinds.isEmpty {
+ queueAndTimingItems.append(contentsOf: coordinator.markerBehaviorMenus(
+ kinds: availableMarkerKinds,
+ preferences: markerPreferences
+ ))
+ }
+ queueAndTimingItems.append(coordinator.sleepTimerMenu(
+ selected: sleepTimer.preset,
+ canChange: canChangeMediaSelection
+ ))
+
+ var items: [UIMenuElement] = []
+ if !playbackItems.isEmpty {
+ items.append(coordinator.transportGroupMenu(
+ title: "Playback",
+ systemImage: "slider.horizontal.3",
+ children: playbackItems
+ ))
+ }
+ items.append(coordinator.transportGroupMenu(
+ title: "Queue & Timing",
+ systemImage: "text.line.first.and.arrowtriangle.forward",
+ children: queueAndTimingItems
+ ))
+ controller.transportBarCustomMenuItems = items
+ }
+
+ private func configureInfoActions(
+ _ controller: AVPlayerViewController,
+ coordinator: Coordinator
+ ) {
+ guard coordinator.shouldUpdateInfoActions(
+ hasNextItem: canGoNext,
+ nextItemTitle: nextItemTitle,
+ controlsEnabled: canChangeMediaSelection
+ ) else {
+ return
+ }
+ controller.infoViewActions = coordinator.infoActions(
+ hasNextItem: canGoNext,
+ nextItemTitle: nextItemTitle,
+ controlsEnabled: canChangeMediaSelection
+ )
+ }
+
+ @MainActor
+ final class Coordinator: NSObject, AVPlayerViewControllerDelegate, UIGestureRecognizerDelegate {
+ var selectVideoQuality: (PlexVideoQuality) -> Void
+ var selectMusicQuality: (PlexMusicQuality) -> Void
+ var selectAudioBoost: (PlexAudioBoost) -> Void
+ var selectSubtitleSize: (PlexSubtitleSize) -> Void
+ var setSubtitleAutoSync: (Bool) -> Void
+ var setSubtitleOffset: (Int) -> Void
+ var setVideoConversionForced: (Bool) -> Void
+ var selectPlaybackVersion: (Int) -> Void
+ var selectVideoScalingMode: (PlexVideoScalingMode) -> Void
+ var selectAudioStream: (Int) -> Void
+ var selectSubtitleStream: (Int?) -> Void
+ var restartFromBeginning: () -> Void
+ var playPreviousItem: () -> Void
+ var playNextItem: () -> Void
+ var playQueuedItem: (String) -> Void
+ var moveQueuedItem: (String, PlexPlayQueueItemMoveDirection) -> Void
+ var removeQueuedItem: (String) -> Void
+ var setShuffled: (Bool) -> Void
+ var setRepeatMode: (PlexPlaybackRepeatMode) -> Void
+ var setSleepTimer: (PlexPlaybackSleepTimerPreset) -> Void
+ var setMarkerBehavior: (PlexPlaybackMarkerBehavior, PlexPlaybackMarkerKind) -> Void
+ var skipMarker: () -> Void
+ var shouldPresentContentProposal: (AVContentProposal) -> Bool
+ var acceptContentProposal: (AVContentProposal) -> Void
+ var rejectContentProposal: (AVContentProposal) -> Void
+ var dismissPlayer: () -> Void
+ var recordInteraction: () -> Void
+ var shouldHandlePlayPausePress: () -> Bool
+ var handlePlayPausePress: () -> Bool
+ private var transportMenuConfiguration: TransportMenuConfiguration?
+ private var infoActionConfiguration: InfoActionConfiguration?
+ private var playbackInfo: PlexPlaybackInfoPresentation?
+ private var playbackInfoController: UIHostingController?
+ private var queuePresentation: PlexPlaybackQueuePresentation?
+ private var canSelectQueueItems = false
+ private var playbackQueueController: UIHostingController?
+ private var markerAction: PlexPlaybackMarkerAction?
+ private let audioStageHost = TVAudioPlayerStageHost()
+ private weak var playPauseRecognizer: UITapGestureRecognizer?
+ private weak var interactionRecognizer: UITapGestureRecognizer?
+
+ init(
+ selectVideoQuality: @escaping (PlexVideoQuality) -> Void,
+ selectMusicQuality: @escaping (PlexMusicQuality) -> Void,
+ selectAudioBoost: @escaping (PlexAudioBoost) -> Void,
+ selectSubtitleSize: @escaping (PlexSubtitleSize) -> Void,
+ setSubtitleAutoSync: @escaping (Bool) -> Void,
+ setSubtitleOffset: @escaping (Int) -> Void,
+ setVideoConversionForced: @escaping (Bool) -> Void,
+ selectPlaybackVersion: @escaping (Int) -> Void,
+ selectVideoScalingMode: @escaping (PlexVideoScalingMode) -> Void,
+ selectAudioStream: @escaping (Int) -> Void,
+ selectSubtitleStream: @escaping (Int?) -> Void,
+ restartFromBeginning: @escaping () -> Void,
+ playPreviousItem: @escaping () -> Void,
+ playNextItem: @escaping () -> Void,
+ playQueuedItem: @escaping (String) -> Void,
+ moveQueuedItem: @escaping (String, PlexPlayQueueItemMoveDirection) -> Void,
+ removeQueuedItem: @escaping (String) -> Void,
+ setShuffled: @escaping (Bool) -> Void,
+ setRepeatMode: @escaping (PlexPlaybackRepeatMode) -> Void,
+ setSleepTimer: @escaping (PlexPlaybackSleepTimerPreset) -> Void,
+ setMarkerBehavior: @escaping (
+ PlexPlaybackMarkerBehavior,
+ PlexPlaybackMarkerKind
+ ) -> Void,
+ skipMarker: @escaping () -> Void,
+ shouldPresentContentProposal: @escaping (AVContentProposal) -> Bool,
+ acceptContentProposal: @escaping (AVContentProposal) -> Void,
+ rejectContentProposal: @escaping (AVContentProposal) -> Void,
+ dismissPlayer: @escaping () -> Void,
+ recordInteraction: @escaping () -> Void,
+ shouldHandlePlayPausePress: @escaping () -> Bool,
+ handlePlayPausePress: @escaping () -> Bool
+ ) {
+ self.selectVideoQuality = selectVideoQuality
+ self.selectMusicQuality = selectMusicQuality
+ self.selectAudioBoost = selectAudioBoost
+ self.selectSubtitleSize = selectSubtitleSize
+ self.setSubtitleAutoSync = setSubtitleAutoSync
+ self.setSubtitleOffset = setSubtitleOffset
+ self.setVideoConversionForced = setVideoConversionForced
+ self.selectPlaybackVersion = selectPlaybackVersion
+ self.selectVideoScalingMode = selectVideoScalingMode
+ self.selectAudioStream = selectAudioStream
+ self.selectSubtitleStream = selectSubtitleStream
+ self.restartFromBeginning = restartFromBeginning
+ self.playPreviousItem = playPreviousItem
+ self.playNextItem = playNextItem
+ self.playQueuedItem = playQueuedItem
+ self.moveQueuedItem = moveQueuedItem
+ self.removeQueuedItem = removeQueuedItem
+ self.setShuffled = setShuffled
+ self.setRepeatMode = setRepeatMode
+ self.setSleepTimer = setSleepTimer
+ self.setMarkerBehavior = setMarkerBehavior
+ self.skipMarker = skipMarker
+ self.shouldPresentContentProposal = shouldPresentContentProposal
+ self.acceptContentProposal = acceptContentProposal
+ self.rejectContentProposal = rejectContentProposal
+ self.dismissPlayer = dismissPlayer
+ self.recordInteraction = recordInteraction
+ self.shouldHandlePlayPausePress = shouldHandlePlayPausePress
+ self.handlePlayPausePress = handlePlayPausePress
+ super.init()
+ }
+
+ func installRemotePressRecognizers(in playerViewController: AVPlayerViewController) {
+ if playPauseRecognizer?.view === playerViewController.view {
+ return
+ }
+ if let playPauseRecognizer {
+ playPauseRecognizer.view?.removeGestureRecognizer(playPauseRecognizer)
+ }
+
+ let recognizer = UITapGestureRecognizer(
+ target: self,
+ action: #selector(handlePlayPausePress(_:))
+ )
+ recognizer.allowedPressTypes = [
+ NSNumber(value: UIPress.PressType.playPause.rawValue)
+ ]
+ recognizer.delegate = self
+ playerViewController.view.addGestureRecognizer(recognizer)
+ playPauseRecognizer = recognizer
+
+ let interactionRecognizer = UITapGestureRecognizer(
+ target: self,
+ action: #selector(recordRemoteInteraction(_:))
+ )
+ interactionRecognizer.allowedPressTypes = [
+ UIPress.PressType.select,
+ .menu,
+ .playPause,
+ .upArrow,
+ .downArrow,
+ .leftArrow,
+ .rightArrow,
+ .pageUp,
+ .pageDown,
+ ].map { NSNumber(value: $0.rawValue) }
+ interactionRecognizer.cancelsTouchesInView = false
+ interactionRecognizer.delegate = self
+ playerViewController.view.addGestureRecognizer(interactionRecognizer)
+ self.interactionRecognizer = interactionRecognizer
+ }
+
+ func dismantle(from playerViewController: AVPlayerViewController) {
+ if let playPauseRecognizer {
+ playPauseRecognizer.view?.removeGestureRecognizer(playPauseRecognizer)
+ self.playPauseRecognizer = nil
+ }
+ if let interactionRecognizer {
+ interactionRecognizer.view?.removeGestureRecognizer(interactionRecognizer)
+ self.interactionRecognizer = nil
+ }
+ audioStageHost.remove()
+ playbackInfoController = nil
+ playbackQueueController = nil
+ playerViewController.customInfoViewControllers = []
+ }
+
+ @objc private func handlePlayPausePress(_ recognizer: UITapGestureRecognizer) {
+ guard recognizer.state == .ended else { return }
+ recordInteraction()
+ _ = handlePlayPausePress()
+ }
+
+ @objc private func recordRemoteInteraction(_ recognizer: UITapGestureRecognizer) {
+ guard recognizer.state == .ended else { return }
+ recordInteraction()
+ }
+
+ func gestureRecognizer(
+ _ gestureRecognizer: UIGestureRecognizer,
+ shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
+ ) -> Bool {
+ gestureRecognizer === interactionRecognizer
+ || otherGestureRecognizer === interactionRecognizer
+ }
+
+ func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
+ guard gestureRecognizer === playPauseRecognizer else { return true }
+ return shouldHandlePlayPausePress()
+ }
+
+ func updateContextualAction(
+ _ action: PlexPlaybackMarkerAction?,
+ in playerViewController: AVPlayerViewController
+ ) {
+ guard action != markerAction else { return }
+ markerAction = action
+
+ guard let action else {
+ playerViewController.contextualActions = []
+ return
+ }
+ playerViewController.contextualActions = [
+ UIAction(
+ title: action.label,
+ image: UIImage(systemName: "forward.end.fill"),
+ discoverabilityTitle: action.accessibilityHint
+ ) { [weak self] _ in
+ self?.skipMarker()
+ },
+ ]
+ }
+
+ func playerViewController(
+ _ playerViewController: AVPlayerViewController,
+ shouldPresent proposal: AVContentProposal
+ ) -> Bool {
+ shouldPresentContentProposal(proposal)
+ }
+
+ func playerViewController(
+ _ playerViewController: AVPlayerViewController,
+ didAccept proposal: AVContentProposal
+ ) {
+ acceptContentProposal(proposal)
+ }
+
+ func playerViewController(
+ _ playerViewController: AVPlayerViewController,
+ didReject proposal: AVContentProposal
+ ) {
+ rejectContentProposal(proposal)
+ }
+
+ func playerViewControllerShouldDismiss(
+ _ playerViewController: AVPlayerViewController
+ ) -> Bool {
+ dismissPlayer()
+ return false
+ }
+
+ func playerViewController(
+ _ playerViewController: AVPlayerViewController,
+ willResumePlaybackAfterUserNavigatedFrom oldTime: CMTime,
+ to targetTime: CMTime
+ ) {
+ recordInteraction()
+ }
+
+ func playerViewController(
+ _ playerViewController: AVPlayerViewController,
+ didSelect mediaSelectionOption: AVMediaSelectionOption?,
+ in mediaSelectionGroup: AVMediaSelectionGroup
+ ) {
+ recordInteraction()
+ }
+
+ func skipToNextItem(for playerViewController: AVPlayerViewController) {
+ playNextItem()
+ }
+
+ func skipToPreviousItem(for playerViewController: AVPlayerViewController) {
+ playPreviousItem()
+ }
+
+ func playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(
+ _ playerViewController: AVPlayerViewController
+ ) -> Bool {
+ false
+ }
+
+ func playerViewController(
+ _ playerViewController: AVPlayerViewController,
+ restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void
+ ) {
+ completionHandler(true)
+ }
+
+ func updatePlaybackInfo(
+ _ presentation: PlexPlaybackInfoPresentation?,
+ in playerViewController: AVPlayerViewController
+ ) {
+ guard presentation != playbackInfo else { return }
+ playbackInfo = presentation
+
+ guard let presentation else {
+ playbackInfoController = nil
+ publishInfoControllers(in: playerViewController)
+ return
+ }
+
+ if let playbackInfoController {
+ playbackInfoController.rootView = TVPlaybackInfoView(presentation: presentation)
+ publishInfoControllers(in: playerViewController)
+ return
+ }
+
+ let infoController = UIHostingController(
+ rootView: TVPlaybackInfoView(presentation: presentation)
+ )
+ infoController.title = "Playback Info"
+ infoController.preferredContentSize = CGSize(width: 1_600, height: 600)
+ infoController.view.backgroundColor = .clear
+ playbackInfoController = infoController
+ publishInfoControllers(in: playerViewController)
+ }
+
+ func updateAudioStage(
+ _ presentation: PlexAudioPlaybackPresentation?,
+ store: TVAppStore,
+ in playerViewController: AVPlayerViewController
+ ) {
+ audioStageHost.update(
+ in: playerViewController.contentOverlayView,
+ store: store,
+ presentation: presentation
+ )
+ }
+
+ func updatePlaybackQueue(
+ _ presentation: PlexPlaybackQueuePresentation?,
+ store: TVAppStore,
+ canSelectItems: Bool,
+ in playerViewController: AVPlayerViewController
+ ) {
+ guard presentation != queuePresentation
+ || canSelectItems != canSelectQueueItems else {
+ return
+ }
+ queuePresentation = presentation
+ canSelectQueueItems = canSelectItems
+
+ guard let presentation, !presentation.upcomingItems.isEmpty else {
+ playbackQueueController = nil
+ publishInfoControllers(in: playerViewController)
+ return
+ }
+
+ let rootView = TVPlaybackQueueHostView(
+ store: store,
+ presentation: presentation,
+ canSelectItems: canSelectItems
+ ) { [weak self] playQueueItemID in
+ self?.playQueuedItem(playQueueItemID)
+ } move: { [weak self] playQueueItemID, direction in
+ self?.moveQueuedItem(playQueueItemID, direction)
+ } remove: { [weak self] playQueueItemID in
+ self?.removeQueuedItem(playQueueItemID)
+ }
+ if let playbackQueueController {
+ playbackQueueController.rootView = rootView
+ publishInfoControllers(in: playerViewController)
+ return
+ }
+
+ let queueController = UIHostingController(rootView: rootView)
+ queueController.title = "Up Next"
+ queueController.preferredContentSize = CGSize(width: 1_600, height: 600)
+ queueController.view.backgroundColor = .clear
+ playbackQueueController = queueController
+ publishInfoControllers(in: playerViewController)
+ }
+
+ private func publishInfoControllers(in playerViewController: AVPlayerViewController) {
+ playerViewController.customInfoViewControllers = [
+ playbackQueueController,
+ playbackInfoController,
+ ].compactMap { $0 }
+ }
+
+ func shouldUpdateTransportMenu(
+ selected: PlexVideoQuality,
+ selectedMusicQuality: PlexMusicQuality,
+ selectedAudioBoost: PlexAudioBoost,
+ selectedSubtitleSize: PlexSubtitleSize,
+ automaticallySyncsSubtitles: Bool,
+ automaticallyAdjustsVideoQuality: Bool,
+ videoConversionControl: TVVideoConversionControl?,
+ scalingMode: PlexVideoScalingMode,
+ showsVideoQualityMenu: Bool,
+ showsMusicQualityMenu: Bool,
+ showsAudioBoostMenu: Bool,
+ showsSubtitleSizeMenu: Bool,
+ showsSubtitleAutoSyncAction: Bool,
+ subtitleOffsetSelection: PlexSubtitleOffsetSelection?,
+ playbackVersionSelection: PlexPlaybackVersionSelection?,
+ mediaSelection: PlexServerManagedMediaSelection,
+ canChangeMediaSelection: Bool,
+ canGoPrevious: Bool,
+ canGoNext: Bool,
+ previousItemTitle: String?,
+ nextItemTitle: String?,
+ canChangeShuffle: Bool,
+ isShuffled: Bool,
+ repeatMode: PlexPlaybackRepeatMode,
+ canRepeatAll: Bool,
+ sleepTimer: PlexPlaybackSleepTimer,
+ availableMarkerKinds: [PlexPlaybackMarkerKind],
+ markerPreferences: PlexPlaybackMarkerPreferences
+ ) -> Bool {
+ let configuration = TransportMenuConfiguration(
+ selectedVideoQuality: selected,
+ selectedMusicQuality: selectedMusicQuality,
+ selectedAudioBoost: selectedAudioBoost,
+ selectedSubtitleSize: selectedSubtitleSize,
+ automaticallySyncsSubtitles: automaticallySyncsSubtitles,
+ automaticallyAdjustsVideoQuality: automaticallyAdjustsVideoQuality,
+ videoConversionControl: videoConversionControl,
+ selectedVideoScalingMode: scalingMode,
+ showsVideoQualityMenu: showsVideoQualityMenu,
+ showsMusicQualityMenu: showsMusicQualityMenu,
+ showsAudioBoostMenu: showsAudioBoostMenu,
+ showsSubtitleSizeMenu: showsSubtitleSizeMenu,
+ showsSubtitleAutoSyncAction: showsSubtitleAutoSyncAction,
+ subtitleOffsetSelection: subtitleOffsetSelection,
+ playbackVersionSelection: playbackVersionSelection,
+ mediaSelection: mediaSelection,
+ canChangeMediaSelection: canChangeMediaSelection,
+ canGoPrevious: canGoPrevious,
+ canGoNext: canGoNext,
+ previousItemTitle: previousItemTitle,
+ nextItemTitle: nextItemTitle,
+ canChangeShuffle: canChangeShuffle,
+ isShuffled: isShuffled,
+ repeatMode: repeatMode,
+ canRepeatAll: canRepeatAll,
+ sleepTimer: sleepTimer,
+ availableMarkerKinds: availableMarkerKinds,
+ markerPreferences: markerPreferences
+ )
+ guard configuration != transportMenuConfiguration else { return false }
+ transportMenuConfiguration = configuration
+ return true
+ }
+
+ func playbackVersionMenu(
+ selection: PlexPlaybackVersionSelection,
+ canChange: Bool
+ ) -> UIMenu {
+ let actions = selection.options.map { option in
+ UIAction(
+ title: option.label,
+ attributes: canChange ? [] : .disabled,
+ state: option.id == selection.selectedID ? .on : .off
+ ) { [weak self] _ in
+ self?.selectPlaybackVersion(option.id)
+ }
+ }
+ return UIMenu(
+ title: "Version",
+ image: UIImage(systemName: "rectangle.stack.fill"),
+ options: .singleSelection,
+ children: actions
+ )
+ }
+
+ func qualityMenu(
+ selected: PlexVideoQuality,
+ automaticallyAdjusts: Bool,
+ canChange: Bool
+ ) -> UIMenu {
+ let actions = PlexVideoQuality.allCases.map { quality in
+ UIAction(
+ title: quality.label,
+ attributes: canChange ? [] : .disabled,
+ state: quality == selected ? .on : .off
+ ) { [weak self] _ in
+ self?.selectVideoQuality(quality)
+ }
+ }
+ return UIMenu(
+ title: automaticallyAdjusts ? "Starting Quality" : "Quality",
+ image: UIImage(systemName: "4k.tv"),
+ options: .singleSelection,
+ children: actions
+ )
+ }
+
+ func musicQualityMenu(
+ selected: PlexMusicQuality,
+ canChange: Bool
+ ) -> UIMenu {
+ let actions = PlexMusicQuality.allCases.map { quality in
+ UIAction(
+ title: quality.label,
+ attributes: canChange ? [] : .disabled,
+ state: quality == selected ? .on : .off
+ ) { [weak self] _ in
+ self?.selectMusicQuality(quality)
+ }
+ }
+ return UIMenu(
+ title: "Music Quality",
+ image: UIImage(systemName: "waveform"),
+ options: .singleSelection,
+ children: actions
+ )
+ }
+
+ func audioBoostMenu(
+ selected: PlexAudioBoost,
+ canChange: Bool
+ ) -> UIMenu {
+ let actions = PlexAudioBoost.allCases.map { boost in
+ UIAction(
+ title: boost.label,
+ subtitle: boost.percentageLabel,
+ attributes: canChange ? [] : .disabled,
+ state: boost == selected ? .on : .off
+ ) { [weak self] _ in
+ self?.selectAudioBoost(boost)
+ }
+ }
+ return UIMenu(
+ title: "Audio Boost",
+ subtitle: "Transcoded surround to stereo",
+ image: UIImage(systemName: "speaker.wave.3.fill"),
+ options: .singleSelection,
+ children: actions
+ )
+ }
+
+ func subtitleSizeMenu(
+ selected: PlexSubtitleSize,
+ canChange: Bool
+ ) -> UIMenu {
+ let actions = PlexSubtitleSize.allCases.map { size in
+ UIAction(
+ title: size.label,
+ subtitle: size.percentageLabel,
+ attributes: canChange ? [] : .disabled,
+ state: size == selected ? .on : .off
+ ) { [weak self] _ in
+ self?.selectSubtitleSize(size)
+ }
+ }
+ return UIMenu(
+ title: "Subtitle Size",
+ image: UIImage(systemName: "textformat.size"),
+ options: .singleSelection,
+ children: actions
+ )
+ }
+
+ func subtitleAutoSyncAction(
+ isEnabled: Bool,
+ canChange: Bool
+ ) -> UIAction {
+ UIAction(
+ title: "Auto-Sync Subtitles",
+ subtitle: "Align timing to detected dialogue",
+ image: UIImage(systemName: "captions.bubble.fill"),
+ attributes: canChange ? [] : .disabled,
+ state: isEnabled ? .on : .off
+ ) { [weak self] _ in
+ self?.setSubtitleAutoSync(!isEnabled)
+ }
+ }
+
+ func subtitleOffsetMenu(
+ selection: PlexSubtitleOffsetSelection,
+ canChange: Bool
+ ) -> UIMenu {
+ let step = PlexSubtitleOffsetSelection.adjustmentStepMilliseconds
+ let attributes: UIMenuElement.Attributes = canChange ? [] : .disabled
+ let decrease = UIAction(
+ title: "Decrease by \(step) ms",
+ image: UIImage(systemName: "minus"),
+ attributes: attributes
+ ) { [weak self] _ in
+ guard let target = selection.adjusted(by: -step) else { return }
+ self?.setSubtitleOffset(target)
+ }
+ let increase = UIAction(
+ title: "Increase by \(step) ms",
+ image: UIImage(systemName: "plus"),
+ attributes: attributes
+ ) { [weak self] _ in
+ guard let target = selection.adjusted(by: step) else { return }
+ self?.setSubtitleOffset(target)
+ }
+ let reset = UIAction(
+ title: "Reset to 0 ms",
+ image: UIImage(systemName: "arrow.counterclockwise"),
+ attributes: canChange && selection.milliseconds != 0 ? [] : .disabled
+ ) { [weak self] _ in
+ self?.setSubtitleOffset(0)
+ }
+ return UIMenu(
+ title: "Subtitle Offset",
+ subtitle: selection.displayValue,
+ image: UIImage(systemName: "captions.bubble.fill"),
+ children: [decrease, increase, reset]
+ )
+ }
+
+ func videoScalingMenu(selected: PlexVideoScalingMode) -> UIMenu {
+ let actions = PlexVideoScalingMode.allCases.map { scalingMode in
+ UIAction(
+ title: scalingMode.label,
+ state: scalingMode == selected ? .on : .off
+ ) { [weak self] _ in
+ self?.selectVideoScalingMode(scalingMode)
+ }
+ }
+ return UIMenu(
+ title: "Video Scaling",
+ image: UIImage(systemName: "arrow.up.left.and.arrow.down.right"),
+ options: .singleSelection,
+ children: actions
+ )
+ }
+
+ func videoConversionAction(
+ control: TVVideoConversionControl,
+ canChange: Bool
+ ) -> UIAction {
+ UIAction(
+ title: control.title,
+ image: UIImage(systemName: control.systemImage),
+ attributes: canChange ? [] : .disabled
+ ) { [weak self] _ in
+ self?.setVideoConversionForced(control.nextValue)
+ }
+ }
+
+ func startOverAction(canChange: Bool) -> UIAction {
+ UIAction(
+ title: "Start Over",
+ image: UIImage(systemName: "backward.end.fill"),
+ attributes: canChange ? [] : .disabled
+ ) { [weak self] _ in
+ self?.restartFromBeginning()
+ }
+ }
+
+ func transportGroupMenu(
+ title: String,
+ systemImage: String,
+ children: [UIMenuElement]
+ ) -> UIMenu {
+ UIMenu(
+ title: title,
+ image: UIImage(systemName: systemImage),
+ children: children
+ )
+ }
+
+ func shouldUpdateInfoActions(
+ hasNextItem: Bool,
+ nextItemTitle: String?,
+ controlsEnabled: Bool
+ ) -> Bool {
+ let configuration = InfoActionConfiguration(
+ hasNextItem: hasNextItem,
+ nextItemTitle: nextItemTitle,
+ controlsEnabled: controlsEnabled
+ )
+ guard configuration != infoActionConfiguration else { return false }
+ infoActionConfiguration = configuration
+ return true
+ }
+
+ func infoActions(
+ hasNextItem: Bool,
+ nextItemTitle: String?,
+ controlsEnabled: Bool
+ ) -> [UIAction] {
+ var actions = [startOverAction(canChange: controlsEnabled)]
+ if hasNextItem {
+ actions.append(UIAction(
+ title: "Play Next",
+ subtitle: nextItemTitle,
+ image: UIImage(systemName: "forward.end.fill"),
+ attributes: controlsEnabled ? [] : .disabled
+ ) { [weak self] _ in
+ self?.playNextItem()
+ })
+ }
+ return actions
+ }
+
+ func queueMenu(
+ canGoPrevious: Bool,
+ canGoNext: Bool,
+ previousItemTitle: String?,
+ nextItemTitle: String?,
+ canChangeShuffle: Bool,
+ isShuffled: Bool
+ ) -> UIMenu {
+ let previous = UIAction(
+ title: "Previous",
+ subtitle: previousItemTitle,
+ image: UIImage(systemName: "backward.end.fill"),
+ attributes: canGoPrevious ? [] : .disabled
+ ) { [weak self] _ in
+ self?.playPreviousItem()
+ }
+ let next = UIAction(
+ title: "Next",
+ subtitle: nextItemTitle,
+ image: UIImage(systemName: "forward.end.fill"),
+ attributes: canGoNext ? [] : .disabled
+ ) { [weak self] _ in
+ self?.playNextItem()
+ }
+ let shuffle = UIAction(
+ title: "Shuffle",
+ image: UIImage(systemName: "shuffle"),
+ attributes: canChangeShuffle ? [] : .disabled,
+ state: isShuffled ? .on : .off
+ ) { [weak self] _ in
+ self?.setShuffled(!isShuffled)
+ }
+ return UIMenu(
+ title: "Queue",
+ image: UIImage(systemName: "text.line.first.and.arrowtriangle.forward"),
+ children: [
+ previous,
+ next,
+ shuffle,
+ ]
+ )
+ }
+
+ func repeatMenu(
+ selected: PlexPlaybackRepeatMode,
+ canRepeatAll: Bool,
+ canChange: Bool
+ ) -> UIMenu {
+ let actions = PlexPlaybackRepeatMode.allCases.map { repeatMode in
+ let isAvailable = canChange && (repeatMode != .all || canRepeatAll)
+ return UIAction(
+ title: repeatMode.label,
+ attributes: isAvailable ? [] : .disabled,
+ state: repeatMode == selected ? .on : .off
+ ) { [weak self] _ in
+ self?.setRepeatMode(repeatMode)
+ }
+ }
+ return UIMenu(
+ title: "Repeat",
+ image: UIImage(systemName: selected == .one ? "repeat.1" : "repeat"),
+ options: .singleSelection,
+ children: actions
+ )
+ }
+
+ func sleepTimerMenu(
+ selected: PlexPlaybackSleepTimerPreset,
+ canChange: Bool
+ ) -> UIMenu {
+ let actions = PlexPlaybackSleepTimerPreset.allCases.map { preset in
+ UIAction(
+ title: preset.label,
+ attributes: canChange ? [] : .disabled,
+ state: preset == selected ? .on : .off
+ ) { [weak self] _ in
+ self?.setSleepTimer(preset)
+ }
+ }
+ return UIMenu(
+ title: "Sleep Timer",
+ image: UIImage(systemName: "moon.zzz.fill"),
+ options: .singleSelection,
+ children: actions
+ )
+ }
+
+ func markerBehaviorMenus(
+ kinds: [PlexPlaybackMarkerKind],
+ preferences: PlexPlaybackMarkerPreferences
+ ) -> [UIMenu] {
+ kinds.map { kind in
+ let actions = PlexPlaybackMarkerBehavior.allCases.map { behavior in
+ UIAction(
+ title: behavior.label,
+ state: preferences.behavior(for: kind) == behavior ? .on : .off
+ ) { [weak self] _ in
+ self?.setMarkerBehavior(behavior, kind)
+ }
+ }
+ return UIMenu(
+ title: "Skip \(kind.settingsLabel)",
+ image: markerMenuImage(for: kind),
+ options: .singleSelection,
+ children: actions
+ )
+ }
+ }
+
+ private func markerMenuImage(for kind: PlexPlaybackMarkerKind) -> UIImage? {
+ switch kind {
+ case .intro:
+ UIImage(systemName: "forward.end.circle.fill")
+ case .commercial:
+ UIImage(systemName: "megaphone.fill")
+ case .credits:
+ UIImage(systemName: "checkered.flag")
+ }
+ }
+
+ func audioMenu(
+ options: [PlexMediaSelectionOption],
+ canChange: Bool
+ ) -> UIMenu {
+ let actions = options.map { option in
+ UIAction(
+ title: option.title,
+ attributes: canChange ? [] : .disabled,
+ state: option.isSelected ? .on : .off
+ ) { [weak self] _ in
+ self?.selectAudioStream(option.id)
+ }
+ }
+ return UIMenu(
+ title: "Audio Track",
+ image: UIImage(systemName: "speaker.wave.2.fill"),
+ options: .singleSelection,
+ children: actions
+ )
+ }
+
+ func subtitleMenu(
+ options: [PlexMediaSelectionOption],
+ canChange: Bool
+ ) -> UIMenu {
+ let attributes: UIMenuElement.Attributes = canChange ? [] : .disabled
+ let selectedStreamID = options.first(where: \.isSelected)?.id
+ let off = UIAction(
+ title: "Off",
+ attributes: attributes,
+ state: selectedStreamID == nil ? .on : .off
+ ) { [weak self] _ in
+ self?.selectSubtitleStream(nil)
+ }
+ let actions = options.map { option in
+ UIAction(
+ title: option.title,
+ attributes: attributes,
+ state: option.isSelected ? .on : .off
+ ) { [weak self] _ in
+ self?.selectSubtitleStream(option.id)
+ }
+ }
+ return UIMenu(
+ title: "Subtitles",
+ image: UIImage(systemName: "captions.bubble.fill"),
+ options: .singleSelection,
+ children: [off] + actions
+ )
+ }
+
+ private struct TransportMenuConfiguration: Equatable {
+ let selectedVideoQuality: PlexVideoQuality
+ let selectedMusicQuality: PlexMusicQuality
+ let selectedAudioBoost: PlexAudioBoost
+ let selectedSubtitleSize: PlexSubtitleSize
+ let automaticallySyncsSubtitles: Bool
+ let automaticallyAdjustsVideoQuality: Bool
+ let videoConversionControl: TVVideoConversionControl?
+ let selectedVideoScalingMode: PlexVideoScalingMode
+ let showsVideoQualityMenu: Bool
+ let showsMusicQualityMenu: Bool
+ let showsAudioBoostMenu: Bool
+ let showsSubtitleSizeMenu: Bool
+ let showsSubtitleAutoSyncAction: Bool
+ let subtitleOffsetSelection: PlexSubtitleOffsetSelection?
+ let playbackVersionSelection: PlexPlaybackVersionSelection?
+ let mediaSelection: PlexServerManagedMediaSelection
+ let canChangeMediaSelection: Bool
+ let canGoPrevious: Bool
+ let canGoNext: Bool
+ let previousItemTitle: String?
+ let nextItemTitle: String?
+ let canChangeShuffle: Bool
+ let isShuffled: Bool
+ let repeatMode: PlexPlaybackRepeatMode
+ let canRepeatAll: Bool
+ let sleepTimer: PlexPlaybackSleepTimer
+ let availableMarkerKinds: [PlexPlaybackMarkerKind]
+ let markerPreferences: PlexPlaybackMarkerPreferences
+ }
+
+ private struct InfoActionConfiguration: Equatable {
+ let hasNextItem: Bool
+ let nextItemTitle: String?
+ let controlsEnabled: Bool
+ }
+ }
+}
+
+@MainActor
+@Observable
+final class TVPlaybackSession {
+ @ObservationIgnored private let makePlayerItem: (URL) -> AVPlayerItem
+
+ init(makePlayerItem: @escaping (URL) -> AVPlayerItem = { AVPlayerItem(url: $0) }) {
+ self.makePlayerItem = makePlayerItem
+ }
+
+ private(set) var player: AVPlayer?
+ private(set) var errorMessage: String?
+ private(set) var playbackMediaKind: PlexPlaybackMediaKind = .video
+ private(set) var playbackMethod: PlexPlaybackPlan.Method?
+ private(set) var selectedVideoQuality: PlexVideoQuality?
+ private(set) var selectedMusicQuality: PlexMusicQuality?
+ private(set) var selectedAudioBoost: PlexAudioBoost?
+ private(set) var supportsAudioBoost = false
+ private(set) var supportsSubtitleAutoSync = false
+ private(set) var subtitleOffsetSelection: PlexSubtitleOffsetSelection?
+ var hasAvailableSubtitles: Bool {
+ mediaSelection?.subtitleOptions.isEmpty == false
+ }
+ var hasSelectedSubtitle: Bool {
+ mediaSelection?.hasSelectedSubtitle == true
+ }
+ var isVideoPlayback: Bool { playbackMediaKind == .video }
+ var isMusicPlayback: Bool { playbackMediaKind == .music }
+ private(set) var audioPresentation: PlexAudioPlaybackPresentation?
+ private(set) var playbackVersionSelection: PlexPlaybackVersionSelection?
+ private(set) var activeMarkerAction: PlexPlaybackMarkerAction?
+ private(set) var availableMarkerKinds: [PlexPlaybackMarkerKind] = []
+ private(set) var upNextItem: PlexMediaItem?
+ private(set) var postPlayTitle = "Up Next"
+ private(set) var postPlayCountdown: Int?
+ private(set) var serverManagedMediaSelection = PlexServerManagedMediaSelection()
+ private(set) var isReconfiguringMediaSelection = false
+ private(set) var mediaSelectionErrorMessage: String?
+ private(set) var canRetryPlayback = true
+ private(set) var playbackInfo: PlexPlaybackInfoPresentation?
+ private(set) var queuePresentation: PlexPlaybackQueuePresentation?
+ private(set) var canGoPrevious = false
+ private(set) var canGoNext = false
+ private(set) var previousItemTitle: String?
+ private(set) var nextItemTitle: String?
+ private(set) var isNavigatingQueue = false
+ private(set) var isMutatingQueue = false
+ private(set) var isReplacingPlayback = false
+ private(set) var canChangeShuffle = false
+ private(set) var isShuffled = false
+ private(set) var repeatMode: PlexPlaybackRepeatMode = .off
+ private(set) var canRepeatAll = false
+ private(set) var queueNavigationErrorMessage: String?
+ private(set) var qualitySuggestion: PlexPlaybackQualitySuggestion?
+ var canRetryQueueOperation: Bool { failedQueueOperation != nil }
+ var isPreparingInitialPosition: Bool { pendingInitialSeekPosition != nil }
+ private var pendingInitialSeekPosition: TimeInterval?
+
+ @ObservationIgnored private var positionObserver: Any?
+ @ObservationIgnored private var endObserver: NSObjectProtocol?
+ @ObservationIgnored private var failedToEndObserver: NSObjectProtocol?
+ @ObservationIgnored private var timeJumpObserver: NSObjectProtocol?
+ @ObservationIgnored private var playbackStalledObserver: NSObjectProtocol?
+ @ObservationIgnored private var audioInterruptionObserver: NSObjectProtocol?
+ @ObservationIgnored private var mediaServicesResetObserver: NSObjectProtocol?
+ @ObservationIgnored private var audioRenderingModeObserver: NSObjectProtocol?
+ @ObservationIgnored private var playerStatusObservation: NSKeyValueObservation?
+ @ObservationIgnored private var playerTimeControlStatusObservation: NSKeyValueObservation?
+ @ObservationIgnored private var playerWaitingReasonObservation: NSKeyValueObservation?
+ @ObservationIgnored private var playerDefaultRateObservation: NSKeyValueObservation?
+ @ObservationIgnored private var playerRateObservation: NSKeyValueObservation?
+ @ObservationIgnored private var playerItemStatusObservation: NSKeyValueObservation?
+ @ObservationIgnored private var timelineTask: Task?
+ @ObservationIgnored private var initialSeekTask: Task?
+ @ObservationIgnored private var mediaSelectionInspectionTask: Task?
+ @ObservationIgnored private var mediaFactsInspectionTask: Task?
+ @ObservationIgnored private var playbackMetricsTask: Task?
+ @ObservationIgnored private var artworkTask: Task?
+ @ObservationIgnored private var chapterArtworkTask: Task?
+ @ObservationIgnored private var contentProposalTask: Task?
+ @ObservationIgnored private var contentProposalEligibilityTask: Task?
+ @ObservationIgnored private var mediaSelectionReconfigurationTask: Task?
+ @ObservationIgnored private var queueNavigationTask: Task?
+ @ObservationIgnored private var queueMutationTask: Task?
+ @ObservationIgnored private var rewindOnResumeTask: Task?
+ @ObservationIgnored private var sleepTimerTask: Task?
+ @ObservationIgnored private var rewindOnResumeTimeJumpToken:
+ PlexPlaybackTimeJumpExpectations.Token?
+ @ObservationIgnored private var preparationEpoch = PlexPlaybackSessionEpoch()
+ @ObservationIgnored private var preparingRequestID: UUID?
+ @ObservationIgnored private var timelineCadence = PlexTimelineReportCadence()
+ @ObservationIgnored private var timelineReporter: PlexTimelineReportSequencer?
+ @ObservationIgnored private var nowPlayingArtwork: MPMediaItemArtwork?
+ @ObservationIgnored private var publishedNowPlayingItemIdentifier: ObjectIdentifier?
+ @ObservationIgnored private var publishedNowPlayingFingerprint:
+ PlexNowPlayingMetadata.PublicationFingerprint?
+ @ObservationIgnored private var publishedNowPlayingStatus: PlexPlaybackStatus?
+ @ObservationIgnored private let userInteractionStore = PlexUserInteractionStore()
+ @ObservationIgnored private var hasPrepared = false
+ @ObservationIgnored private var postPlayTask: Task?
+ @ObservationIgnored private var upNextRequest: TVPlexPlaybackRequest?
+ @ObservationIgnored private var automaticMarkerTransition = PlexAutomaticPlaybackMarkerTransition()
+ @ObservationIgnored private var expectedTimeJumps = PlexPlaybackTimeJumpExpectations()
+ @ObservationIgnored private var nativeMediaSelectionState = PlexNativeMediaSelectionState()
+ @ObservationIgnored private var mediaSelection: PlexPlaybackMediaSelection?
+ @ObservationIgnored private var inspectedMediaItemIdentifier: ObjectIdentifier?
+ @ObservationIgnored private var deliveredMediaFacts: PlexNativeMediaFacts?
+ @ObservationIgnored private var playbackMetricFacts: PlexPlaybackMetricFacts?
+ @ObservationIgnored private var waitingReason: PlexPlaybackWaitingReason?
+ @ObservationIgnored private var needsInitialSeek = false
+ @ObservationIgnored private var currentPlan: PlexPlaybackPlan?
+ @ObservationIgnored private var currentRequest: TVPlexPlaybackRequest?
+ @ObservationIgnored private weak var currentStore: TVAppStore?
+ @ObservationIgnored private var upNextPlaybackRate: PlexPlaybackRate = .normal
+ @ObservationIgnored private var proposedNextRequest: TVPlexPlaybackRequest?
+ @ObservationIgnored private var preparedContentProposal: AVContentProposal?
+ @ObservationIgnored private var authorizedContentProposals: [AVContentProposal] = []
+ @ObservationIgnored private var qualitySuggestionState =
+ PlexPlaybackQualitySuggestionSessionState()
+ @ObservationIgnored private var hasMarkedCurrentItemWatched = false
+ @ObservationIgnored private var hasReachedEnd = false
+ @ObservationIgnored private var handledEndRequestID: UUID?
+ @ObservationIgnored private var hasRejectedContentProposal = false
+ @ObservationIgnored private var stoppedTimelineSessionIdentifier: String?
+ @ObservationIgnored private var stoppedTimelineContinuing: Bool?
+ @ObservationIgnored private var wasPlayingBeforeAudioInterruption = false
+ @ObservationIgnored private var pendingRecovery: (
+ requestID: UUID,
+ request: PlexPlaybackRecoveryRequest,
+ playbackRate: PlexPlaybackRate
+ )?
+ @ObservationIgnored private var failedQueueOperation: TVPlaybackQueueFailureRecovery?
+
+ func prepare(request: TVPlexPlaybackRequest, store: TVAppStore) async {
+ if let currentRequest, currentRequest.id != request.id {
+ if let currentStore {
+ stop(store: currentStore, request: currentRequest)
+ } else {
+ resetPlayer()
+ }
+ }
+ if let preparingRequestID, preparingRequestID != request.id {
+ preparationEpoch.invalidate()
+ self.preparingRequestID = nil
+ hasPrepared = false
+ }
+ guard !hasPrepared else { return }
+ let preparationTicket = preparationEpoch.activate()
+ preparingRequestID = request.id
+ hasPrepared = true
+ errorMessage = nil
+ canRetryPlayback = true
+ if pendingRecovery?.requestID != request.id {
+ pendingRecovery = nil
+ }
+
+ do {
+ let recovery = pendingRecovery?.request
+ let videoQuality = recovery?.videoQuality
+ ?? request.videoQualityOverride
+ ?? store.activeVideoQuality
+ let playbackRequest = recovery.map {
+ request.startingNewPlaybackSession(
+ at: $0.startTime,
+ playbackRate: pendingRecovery?.playbackRate ?? request.playbackRate
+ )
+ } ?? request
+ let plan = try await store.playbackPlan(for: playbackRequest, recovery: recovery)
+ try checkCurrentPreparation(
+ preparationTicket,
+ requestID: request.id
+ )
+ try configureAudioSession(for: plan.mediaKind)
+ let playerItem = makePlayerItem(plan.url)
+ PlexNativeSubtitleStyle.apply(to: playerItem, size: store.subtitleSize)
+ playerItem.externalMetadata = metadata(for: playbackRequest.item)
+ playerItem.navigationMarkerGroups = navigationMarkerGroups(for: playbackRequest.item)
+ playerItem.interstitialTimeRanges = interstitialTimeRanges(for: playbackRequest.item)
+ let player = AVPlayer(playerItem: playerItem)
+ player.defaultRate = playbackRequest.playbackRate.rawValue
+ player.preventsDisplaySleepDuringVideoPlayback = plan.mediaKind == .video
+ prepareMediaSelection(item: playbackRequest.item, source: plan.source)
+ currentPlan = plan
+ playbackMethod = plan.method
+ supportsAudioBoost = plan.supportsAudioBoost
+ supportsSubtitleAutoSync = plan.supportsSubtitleAutoSync
+ playbackMediaKind = plan.mediaKind
+ audioPresentation = PlexAudioPlaybackPresentation(
+ item: playbackRequest.item,
+ source: plan.source
+ )
+ playbackVersionSelection = PlexPlaybackVersionSelection(
+ item: playbackRequest.item,
+ selectedSource: plan.source
+ )
+ availableMarkerKinds = PlexPlaybackMarkerAction.availableKinds(
+ in: playbackRequest.item.markers,
+ duration: playbackRequest.item.durationSeconds
+ )
+ selectedVideoQuality = videoQuality
+ selectedMusicQuality = store.activeMusicQuality
+ selectedAudioBoost = store.audioBoost
+ currentRequest = playbackRequest
+ currentStore = store
+ hasMarkedCurrentItemWatched = false
+ hasReachedEnd = false
+ hasRejectedContentProposal = false
+ stoppedTimelineSessionIdentifier = nil
+ stoppedTimelineContinuing = nil
+ updateQueueControls(for: playbackRequest.queue)
+ if repeatMode == .all, !canRepeatAll {
+ repeatMode = .off
+ }
+ queueNavigationErrorMessage = nil
+ qualitySuggestionState.moveToItem(itemKey: playbackRequest.item.ratingKey)
+ deliveredMediaFacts = nil
+ playbackMetricFacts = nil
+ waitingReason = nil
+ refreshPlaybackInfo()
+ pendingRecovery = nil
+ installObservers(player: player, request: playbackRequest, store: store)
+ self.player = player
+ preparationEpoch.invalidate()
+ preparingRequestID = nil
+ installAudioSessionObservers(for: player)
+ activateNowPlaying()
+ loadArtwork(for: playbackRequest.item, playerItem: playerItem, store: store)
+ loadChapterArtwork(
+ for: playbackRequest.item,
+ playerItem: playerItem,
+ store: store
+ )
+ prepareNextItem(
+ for: playbackRequest,
+ playerItem: playerItem,
+ store: store
+ )
+ inspectNativeMediaSelection(for: playerItem)
+ observePlaybackState(
+ player: player,
+ playerItem: playerItem,
+ plan: plan,
+ request: playbackRequest,
+ store: store
+ )
+ observePlaybackMetrics(of: playerItem, plan: plan)
+ scheduleSleepTimer(store.playbackSleepTimer, store: store)
+ if plan.startTime > 0 {
+ player.pause()
+ updateNowPlaying(force: true)
+ } else {
+ applyAutoplay(playbackRequest.autoplay, to: player)
+ updateNowPlaying(force: true)
+ await reportTimelineIfNeeded(force: true)
+ guard self.player === player else { return }
+ startTimelineReporting()
+ }
+ } catch is CancellationError {
+ guard isCurrentPreparation(preparationTicket, requestID: request.id) else {
+ return
+ }
+ preparationEpoch.invalidate()
+ preparingRequestID = nil
+ hasPrepared = false
+ deactivateAudioSession()
+ } catch {
+ guard isCurrentPreparation(preparationTicket, requestID: request.id) else {
+ return
+ }
+ preparationEpoch.invalidate()
+ preparingRequestID = nil
+ hasPrepared = false
+ deactivateAudioSession()
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ func retry(request: TVPlexPlaybackRequest, store: TVAppStore) async {
+ await prepare(request: request, store: store)
+ }
+
+ func scenePhaseDidChange(_ phase: ScenePhase) {
+ guard player != nil else { return }
+ switch phase {
+ case .active:
+ recordUserInteraction()
+ if let currentStore {
+ scheduleSleepTimer(currentStore.playbackSleepTimer, store: currentStore)
+ }
+ Task { await reportTimelineIfNeeded(force: true) }
+ case .inactive, .background:
+ Task { await reportTimelineIfNeeded(force: true) }
+ @unknown default:
+ break
+ }
+ }
+
+ func recordUserInteraction() {
+ userInteractionStore.recordInteraction()
+ refreshContentProposalEligibility()
+ }
+
+ func shouldHandleNativePlayPausePress() -> Bool {
+ PlexRewindOnResumePolicy.shouldInterceptNativePlayPausePress(
+ status: nowPlayingStatus,
+ hasPendingRewind: rewindOnResumeTask != nil,
+ isPlaybackControlBusy: isPlaybackControlBusy,
+ preference: currentStore?.rewindOnResume ?? .none
+ )
+ }
+
+ func handleNativePlayPausePress() -> Bool {
+ guard let player else { return false }
+ if rewindOnResumeTask != nil {
+ cancelPendingRewindOnResume()
+ return true
+ }
+ guard !isPlaybackControlBusy,
+ let action = PlexPlaybackTransportAction(status: nowPlayingStatus) else {
+ return true
+ }
+
+ switch action {
+ case .play:
+ resumePlaybackWithRewind(player)
+ case .pause:
+ player.pause()
+ refreshWaitingState(for: player)
+ updateNowPlaying(force: true)
+ Task { await reportTimelineIfNeeded(force: true) }
+ }
+ return true
+ }
+
+ func stop(store: TVAppStore, request: TVPlexPlaybackRequest) {
+ guard let player else {
+ resetPlayer()
+ return
+ }
+ let playbackRequest = currentRequest ?? request
+ let time = playbackPosition(for: player)
+ let shouldReportStopped = stoppedTimelineSessionIdentifier
+ != playbackRequest.sessionIdentifier
+ if shouldReportStopped {
+ stoppedTimelineSessionIdentifier = playbackRequest.sessionIdentifier
+ stoppedTimelineContinuing = false
+ }
+ resetPlayer()
+ guard shouldReportStopped else { return }
+ Task {
+ _ = await reportPlayback(
+ of: playbackRequest,
+ state: .stopped,
+ time: time.isFinite ? time : 0,
+ continuing: false,
+ store: store
+ )
+ }
+ }
+
+ func skipActiveMarker() {
+ guard let player,
+ let playerItem = player.currentItem,
+ let requestID = currentRequest?.id,
+ let action = activeMarkerAction else {
+ return
+ }
+ recordUserInteraction()
+ let timeJumpToken = expectedTimeJumps.expect(target: action.targetTime)
+ player.seek(
+ to: CMTime(seconds: action.targetTime, preferredTimescale: 600),
+ toleranceBefore: .zero,
+ toleranceAfter: .zero
+ ) { [weak self, weak player, weak playerItem] completed in
+ Task { @MainActor in
+ guard let self else { return }
+ guard let player,
+ let playerItem,
+ self.player === player,
+ player.currentItem === playerItem,
+ self.currentRequest?.id == requestID else {
+ self.expectedTimeJumps.cancel(timeJumpToken)
+ return
+ }
+ guard completed else {
+ self.expectedTimeJumps.cancel(timeJumpToken)
+ return
+ }
+ self.updateNowPlaying(force: true)
+ await self.reportTimelineIfNeeded(force: true, time: action.targetTime)
+ }
+ }
+ activeMarkerAction = nil
+ }
+
+ func playNextNow() {
+ guard let upNextRequest, let currentStore else { return }
+ recordUserInteraction()
+ postPlayTask?.cancel()
+ contentProposalEligibilityTask?.cancel()
+ contentProposalEligibilityTask = nil
+ currentStore.presentPlayback(
+ upNextRequest.withPlaybackRate(upNextPlaybackRate)
+ )
+ }
+
+ func cancelPostPlay() {
+ guard let player,
+ let currentRequest,
+ let currentStore else {
+ return
+ }
+ recordUserInteraction()
+ postPlayTask?.cancel()
+ postPlayTask = nil
+ upNextItem = nil
+ upNextRequest = nil
+ upNextPlaybackRate = .normal
+ postPlayCountdown = nil
+ isReplacingPlayback = true
+ updateNowPlayingControlAvailability()
+ Task { [weak self, weak player] in
+ guard let self,
+ let player,
+ self.player === player,
+ self.currentRequest?.id == currentRequest.id,
+ await reportStopped(
+ continuing: false,
+ time: completedPlaybackTime
+ ) else {
+ return
+ }
+ currentStore.dismissPlayer()
+ }
+ }
+
+ func shouldPresentContentProposal(_ proposal: AVContentProposal) -> Bool {
+ guard isAuthorizedContentProposal(proposal),
+ player?.currentItem?.nextContentProposal === proposal else {
+ return false
+ }
+ return true
+ }
+
+ func acceptContentProposal(_ proposal: AVContentProposal) {
+ guard isAuthorizedContentProposal(proposal) else { return }
+ guard let player,
+ let currentRequest,
+ let currentStore,
+ let proposedNextRequest else {
+ return
+ }
+ recordUserInteraction()
+ let playbackRate = playbackRate(for: player)
+ self.proposedNextRequest = nil
+ preparedContentProposal = nil
+ authorizedContentProposals = []
+ contentProposalEligibilityTask?.cancel()
+ contentProposalEligibilityTask = nil
+ nextItemTitle = nil
+ player.currentItem?.nextContentProposal = nil
+ isReplacingPlayback = true
+ updateNowPlayingControlAvailability()
+ Task { [weak self, weak player] in
+ guard let self,
+ let player,
+ self.player === player,
+ self.currentRequest?.id == currentRequest.id else {
+ return
+ }
+ guard await reportStopped(
+ continuing: true,
+ time: hasReachedEnd ? completedPlaybackTime : nil
+ ) else {
+ return
+ }
+ markCurrentItemWatchedIfNeeded(
+ currentRequest.item,
+ store: currentStore
+ )
+ currentStore.presentPlayback(
+ proposedNextRequest.withPlaybackRate(playbackRate)
+ )
+ }
+ }
+
+ func playNextItem() {
+ guard let player,
+ let currentRequest,
+ let currentStore,
+ canGoNext,
+ !isNavigatingQueue,
+ !isReplacingPlayback else {
+ return
+ }
+ recordUserInteraction()
+ let playbackRate = playbackRate(for: player)
+ if let proposedNextRequest {
+ self.proposedNextRequest = nil
+ preparedContentProposal = nil
+ authorizedContentProposals = []
+ contentProposalEligibilityTask?.cancel()
+ contentProposalEligibilityTask = nil
+ nextItemTitle = nil
+ player.currentItem?.nextContentProposal = nil
+ isReplacingPlayback = true
+ updateNowPlayingControlAvailability()
+ Task { [weak self, weak player] in
+ guard let self,
+ let player,
+ self.player === player,
+ self.currentRequest?.id == currentRequest.id else {
+ return
+ }
+ guard await reportStopped(continuing: true) else {
+ return
+ }
+ currentStore.presentPlayback(
+ proposedNextRequest.withPlaybackRate(playbackRate)
+ )
+ }
+ return
+ }
+ navigateQueue(
+ .adjacent(.next),
+ player: player,
+ request: currentRequest,
+ store: currentStore
+ )
+ }
+
+ func playPreviousItem() {
+ guard let player,
+ let currentRequest,
+ let currentStore,
+ canGoPrevious,
+ !isNavigatingQueue,
+ !isReplacingPlayback else {
+ return
+ }
+ recordUserInteraction()
+ navigateQueue(
+ .adjacent(.previous),
+ player: player,
+ request: currentRequest,
+ store: currentStore
+ )
+ }
+
+ func playQueuedItem(playQueueItemID: String) {
+ guard let player,
+ let currentRequest,
+ let currentStore,
+ currentRequest.queue?.presentation.upcomingItems.contains(where: {
+ $0.playQueueItemID == playQueueItemID
+ }) == true,
+ !isNavigatingQueue,
+ !isReplacingPlayback else {
+ return
+ }
+ recordUserInteraction()
+ navigateQueue(
+ .item(playQueueItemID),
+ player: player,
+ request: currentRequest,
+ store: currentStore
+ )
+ }
+
+ func rejectContentProposal(_ proposal: AVContentProposal) {
+ guard isAuthorizedContentProposal(proposal) else { return }
+ guard let player,
+ let currentRequest,
+ let currentStore else {
+ return
+ }
+ recordUserInteraction()
+ proposedNextRequest = nil
+ preparedContentProposal = nil
+ authorizedContentProposals = []
+ contentProposalEligibilityTask?.cancel()
+ contentProposalEligibilityTask = nil
+ nextItemTitle = nil
+ player.currentItem?.nextContentProposal = nil
+ hasRejectedContentProposal = true
+ guard hasReachedEnd else { return }
+ isReplacingPlayback = true
+ updateNowPlayingControlAvailability()
+ Task { [weak self, weak player] in
+ guard let self,
+ let player,
+ self.player === player,
+ self.currentRequest?.id == currentRequest.id,
+ await reportStopped(
+ continuing: false,
+ time: completedPlaybackTime
+ ) else {
+ return
+ }
+ currentStore.dismissPlayer()
+ }
+ }
+
+ func dismissQueueNavigationError() {
+ recordUserInteraction()
+ queueNavigationErrorMessage = nil
+ failedQueueOperation = nil
+ }
+
+ func retryQueueOperation() {
+ guard let failedQueueOperation,
+ let player,
+ let currentRequest,
+ let currentStore else {
+ dismissQueueNavigationError()
+ return
+ }
+ recordUserInteraction()
+ queueNavigationErrorMessage = nil
+ self.failedQueueOperation = nil
+
+ switch failedQueueOperation {
+ case .navigation(let destination):
+ navigateQueue(
+ destination,
+ player: player,
+ request: currentRequest,
+ store: currentStore
+ )
+ case .mutation(let mutation):
+ mutateQueue(mutation)
+ case .completion:
+ Task { [weak self, weak player] in
+ guard let self, let player,
+ self.player === player,
+ self.currentRequest?.id == currentRequest.id else { return }
+ self.handledEndRequestID = nil
+ await handlePlaybackEnded(
+ player: player,
+ installedRequest: currentRequest,
+ store: currentStore
+ )
+ }
+ }
+ }
+
+ func setRepeatMode(_ repeatMode: PlexPlaybackRepeatMode) {
+ guard repeatMode != self.repeatMode,
+ !isReplacingPlayback,
+ repeatMode != .all || canRepeatAll else {
+ return
+ }
+ recordUserInteraction()
+ self.repeatMode = repeatMode
+ refreshPreparedNextItem()
+ }
+
+ func setSleepTimer(
+ _ preset: PlexPlaybackSleepTimerPreset,
+ store: TVAppStore
+ ) {
+ recordUserInteraction()
+ store.setPlaybackSleepTimer(preset)
+ scheduleSleepTimer(store.playbackSleepTimer, store: store)
+ }
+
+ func setShuffled(_ shuffled: Bool) {
+ guard let queue = currentRequest?.queue,
+ queue.canChangeShuffle,
+ queue.isShuffled != shuffled else {
+ return
+ }
+ recordUserInteraction()
+ mutateQueue(.shuffled(shuffled))
+ }
+
+ func moveQueuedItem(
+ playQueueItemID: String,
+ direction: PlexPlayQueueItemMoveDirection
+ ) {
+ guard currentRequest?.queue?.moveRequest(
+ for: playQueueItemID,
+ direction: direction
+ ) != nil else {
+ return
+ }
+ recordUserInteraction()
+ mutateQueue(.move(playQueueItemID, direction))
+ }
+
+ func removeQueuedItem(playQueueItemID: String) {
+ guard currentRequest?.queue?.canRemoveUpcomingItem(
+ playQueueItemID: playQueueItemID
+ ) == true else {
+ return
+ }
+ recordUserInteraction()
+ mutateQueue(.remove(playQueueItemID))
+ }
+
+ private func mutateQueue(_ mutation: TVPlaybackQueueMutation) {
+ guard let playerItem = player?.currentItem,
+ let currentRequest,
+ let currentStore,
+ !isNavigatingQueue,
+ !isMutatingQueue,
+ !isReplacingPlayback else {
+ return
+ }
+
+ let requestID = currentRequest.id
+ isMutatingQueue = true
+ queueNavigationErrorMessage = nil
+ failedQueueOperation = nil
+ updateNowPlayingControlAvailability()
+ queueMutationTask?.cancel()
+ queueMutationTask = Task { [weak self, weak playerItem] in
+ guard let self,
+ let playerItem,
+ !Task.isCancelled,
+ self.player?.currentItem === playerItem,
+ self.currentRequest?.id == requestID else {
+ return
+ }
+ do {
+ let updatedRequest: TVPlexPlaybackRequest
+ switch mutation {
+ case .shuffled(let shuffled):
+ updatedRequest = try await currentStore.playbackRequest(
+ bySettingQueueShuffled: shuffled,
+ from: currentRequest
+ )
+ case .move(let playQueueItemID, let direction):
+ updatedRequest = try await currentStore.playbackRequest(
+ byMovingQueueItem: playQueueItemID,
+ direction: direction,
+ from: currentRequest
+ )
+ case .remove(let playQueueItemID):
+ updatedRequest = try await currentStore.playbackRequest(
+ byRemovingQueueItem: playQueueItemID,
+ from: currentRequest
+ )
+ }
+ guard !Task.isCancelled,
+ self.player?.currentItem === playerItem,
+ self.currentRequest?.id == requestID else {
+ return
+ }
+ self.currentRequest = updatedRequest
+ isMutatingQueue = false
+ updateQueueControls(for: updatedRequest.queue)
+ refreshPlaybackInfo()
+ updateNowPlayingControlAvailability()
+ updateNowPlaying(force: true)
+ prepareNextItem(
+ for: updatedRequest,
+ playerItem: playerItem,
+ store: currentStore
+ )
+ } catch is CancellationError {
+ return
+ } catch {
+ guard self.player?.currentItem === playerItem,
+ self.currentRequest?.id == requestID else {
+ return
+ }
+ isMutatingQueue = false
+ failedQueueOperation = .mutation(mutation)
+ queueNavigationErrorMessage = error.localizedDescription
+ updateNowPlayingControlAvailability()
+ }
+ }
+ }
+
+ private func navigateQueue(
+ _ destination: TVPlaybackQueueDestination,
+ player: AVPlayer,
+ request: TVPlexPlaybackRequest,
+ store: TVAppStore
+ ) {
+ let requestID = request.id
+ let wasPlaying = autoplayAfterReconfiguration(for: player)
+ let playbackRate = playbackRate(for: player)
+ isNavigatingQueue = true
+ queueNavigationErrorMessage = nil
+ failedQueueOperation = nil
+ player.pause()
+ updateNowPlayingControlAvailability()
+ updateNowPlaying(force: true)
+
+ queueNavigationTask?.cancel()
+ queueNavigationTask = Task { [weak self, weak player] in
+ guard let self,
+ let player,
+ !Task.isCancelled,
+ self.player === player,
+ self.currentRequest?.id == requestID else {
+ return
+ }
+ do {
+ let nextRequest: TVPlexPlaybackRequest
+ switch destination {
+ case .adjacent(let direction):
+ nextRequest = try await store.preparedQueueAdvance(
+ from: request,
+ direction: direction,
+ autoplay: true,
+ playbackRate: playbackRate
+ )
+ case .item(let playQueueItemID):
+ nextRequest = try await store.preparedQueueSelection(
+ from: request,
+ playQueueItemID: playQueueItemID,
+ autoplay: true,
+ playbackRate: playbackRate
+ )
+ }
+ guard !Task.isCancelled,
+ self.player === player,
+ self.currentRequest?.id == requestID else {
+ return
+ }
+ guard await reportStopped(continuing: true) else {
+ return
+ }
+ guard !Task.isCancelled,
+ self.player === player,
+ self.currentRequest?.id == requestID else {
+ return
+ }
+ isNavigatingQueue = false
+ proposedNextRequest = nil
+ player.currentItem?.nextContentProposal = nil
+ store.presentPlayback(nextRequest)
+ } catch is CancellationError {
+ return
+ } catch {
+ guard self.player === player,
+ self.currentRequest?.id == requestID else {
+ return
+ }
+ isNavigatingQueue = false
+ failedQueueOperation = .navigation(destination)
+ queueNavigationErrorMessage = error.localizedDescription
+ applyAutoplay(wasPlaying, to: player)
+ updateNowPlayingControlAvailability()
+ updateNowPlaying(force: true)
+ }
+ }
+ }
+
+ private func updateQueueControls(for queue: PlexPlaybackQueue?) {
+ queuePresentation = queue?.presentation
+ canGoPrevious = queue?.canMovePrevious == true
+ canGoNext = queue?.canMoveNext == true
+ previousItemTitle = queue?.previousItem?.title
+ nextItemTitle = queue?.nextItem?.title
+ canChangeShuffle = queue?.canChangeShuffle == true
+ isShuffled = queue?.isShuffled == true
+ canRepeatAll = queue?.canRepeatAll == true
+ }
+
+ private func refreshPreparedNextItem() {
+ contentProposalTask?.cancel()
+ contentProposalEligibilityTask?.cancel()
+ contentProposalEligibilityTask = nil
+ proposedNextRequest = nil
+ preparedContentProposal = nil
+ authorizedContentProposals = []
+ player?.currentItem?.nextContentProposal = nil
+ guard repeatMode != .one,
+ let playerItem = player?.currentItem,
+ let currentRequest,
+ let currentStore else {
+ return
+ }
+ prepareNextItem(
+ for: currentRequest,
+ playerItem: playerItem,
+ store: currentStore
+ )
+ }
+
+ func selectVideoQuality(_ quality: PlexVideoQuality) {
+ guard selectedVideoQuality != quality else { return }
+ recordUserInteraction()
+ qualitySuggestion = nil
+ qualitySuggestionState.suppress()
+ changeVideoQuality(to: quality)
+ }
+
+ func selectMusicQuality(_ quality: PlexMusicQuality) {
+ guard selectedMusicQuality != quality,
+ !isReconfiguringMediaSelection,
+ !isReplacingPlayback,
+ !isNavigatingQueue,
+ !isMutatingQueue,
+ let player,
+ let currentRequest,
+ let currentStore,
+ isMusicPlayback,
+ currentStore.connection?.kind != .local else {
+ return
+ }
+ recordUserInteraction()
+ let startTime = playbackPosition(for: player)
+ let autoplay = autoplayAfterReconfiguration(for: player)
+ let playbackRate = playbackRate(for: player)
+ isReplacingPlayback = true
+ mediaSelectionErrorMessage = nil
+ player.pause()
+ updateNowPlayingControlAvailability()
+ updateNowPlaying(force: true)
+
+ Task { [weak self, weak player] in
+ guard let self,
+ let player,
+ self.player === player,
+ self.currentRequest?.id == currentRequest.id else {
+ return
+ }
+ guard await reportStopped(continuing: nil, time: startTime) else {
+ return
+ }
+ currentStore.changeMusicQuality(
+ to: quality,
+ for: currentRequest.item,
+ queue: currentRequest.queue,
+ source: currentPlan?.source,
+ queueSourcePreference: currentRequest.queueSourcePreference,
+ at: startTime,
+ autoplay: autoplay,
+ playbackRate: playbackRate,
+ videoQualityOverride: currentRequest.videoQualityOverride,
+ forceVideoTranscode: currentRequest.forceVideoTranscode
+ )
+ }
+ }
+
+ func selectAudioBoost(_ audioBoost: PlexAudioBoost) {
+ guard selectedAudioBoost != audioBoost,
+ supportsAudioBoost,
+ !isReconfiguringMediaSelection,
+ !isReplacingPlayback,
+ !isNavigatingQueue,
+ !isMutatingQueue,
+ let player,
+ let currentRequest,
+ let currentStore,
+ isVideoPlayback else {
+ return
+ }
+ recordUserInteraction()
+ let startTime = playbackPosition(for: player)
+ let autoplay = autoplayAfterReconfiguration(for: player)
+ let playbackRate = playbackRate(for: player)
+ isReplacingPlayback = true
+ mediaSelectionErrorMessage = nil
+ player.pause()
+ updateNowPlayingControlAvailability()
+ updateNowPlaying(force: true)
+
+ Task { [weak self, weak player] in
+ guard let self,
+ let player,
+ self.player === player,
+ self.currentRequest?.id == currentRequest.id else {
+ return
+ }
+ guard await reportStopped(continuing: nil, time: startTime) else {
+ return
+ }
+ currentStore.changeAudioBoost(
+ to: audioBoost,
+ for: currentRequest.item,
+ queue: currentRequest.queue,
+ source: currentPlan?.source,
+ queueSourcePreference: currentRequest.queueSourcePreference,
+ at: startTime,
+ autoplay: autoplay,
+ playbackRate: playbackRate,
+ videoQualityOverride: currentRequest.videoQualityOverride,
+ forceVideoTranscode: currentRequest.forceVideoTranscode
+ )
+ }
+ }
+
+ func setSubtitleAutoSync(_ isEnabled: Bool) {
+ guard currentStore?.automaticallySyncSubtitles != isEnabled,
+ supportsSubtitleAutoSync,
+ !isReconfiguringMediaSelection,
+ !isReplacingPlayback,
+ !isNavigatingQueue,
+ !isMutatingQueue,
+ let player,
+ let currentRequest,
+ let currentStore,
+ isVideoPlayback else {
+ return
+ }
+ recordUserInteraction()
+ let startTime = playbackPosition(for: player)
+ let autoplay = autoplayAfterReconfiguration(for: player)
+ let playbackRate = playbackRate(for: player)
+ isReplacingPlayback = true
+ mediaSelectionErrorMessage = nil
+ player.pause()
+ updateNowPlayingControlAvailability()
+ updateNowPlaying(force: true)
+
+ Task { [weak self, weak player] in
+ guard let self,
+ let player,
+ self.player === player,
+ self.currentRequest?.id == currentRequest.id else {
+ return
+ }
+ guard await reportStopped(continuing: nil, time: startTime) else {
+ return
+ }
+ currentStore.changeSubtitleAutoSync(
+ isEnabled: isEnabled,
+ for: currentRequest.item,
+ queue: currentRequest.queue,
+ source: currentPlan?.source,
+ queueSourcePreference: currentRequest.queueSourcePreference,
+ at: startTime,
+ autoplay: autoplay,
+ playbackRate: playbackRate,
+ videoQualityOverride: currentRequest.videoQualityOverride,
+ forceVideoTranscode: currentRequest.forceVideoTranscode
+ )
+ }
+ }
+
+ func selectSubtitleSize(_ subtitleSize: PlexSubtitleSize) {
+ guard currentStore?.subtitleSize != subtitleSize,
+ !isReconfiguringMediaSelection,
+ !isReplacingPlayback,
+ !isNavigatingQueue,
+ !isMutatingQueue,
+ let player,
+ let currentRequest,
+ let currentStore,
+ isVideoPlayback else {
+ return
+ }
+ recordUserInteraction()
+ guard hasSelectedSubtitle else {
+ currentStore.subtitleSize = subtitleSize
+ PlexNativeSubtitleStyle.apply(to: player.currentItem, size: subtitleSize)
+ return
+ }
+ let startTime = playbackPosition(for: player)
+ let autoplay = autoplayAfterReconfiguration(for: player)
+ let playbackRate = playbackRate(for: player)
+ isReplacingPlayback = true
+ mediaSelectionErrorMessage = nil
+ player.pause()
+ updateNowPlayingControlAvailability()
+ updateNowPlaying(force: true)
+
+ Task { [weak self, weak player] in
+ guard let self,
+ let player,
+ self.player === player,
+ self.currentRequest?.id == currentRequest.id else {
+ return
+ }
+ guard await reportStopped(continuing: nil, time: startTime) else {
+ return
+ }
+ currentStore.changeSubtitleSize(
+ to: subtitleSize,
+ for: currentRequest.item,
+ queue: currentRequest.queue,
+ source: currentPlan?.source,
+ queueSourcePreference: currentRequest.queueSourcePreference,
+ at: startTime,
+ autoplay: autoplay,
+ playbackRate: playbackRate,
+ videoQualityOverride: currentRequest.videoQualityOverride,
+ forceVideoTranscode: currentRequest.forceVideoTranscode
+ )
+ }
+ }
+
+ func setSubtitleOffset(_ milliseconds: Int) {
+ guard let selection = subtitleOffsetSelection,
+ selection.milliseconds != milliseconds,
+ !isReconfiguringMediaSelection,
+ !isReplacingPlayback,
+ !isNavigatingQueue,
+ !isMutatingQueue,
+ let player,
+ let currentRequest,
+ let currentStore,
+ isVideoPlayback else {
+ return
+ }
+ recordUserInteraction()
+ let requestID = currentRequest.id
+ let startTime = playbackPosition(for: player)
+ let autoplay = autoplayAfterReconfiguration(for: player)
+ let playbackRate = playbackRate(for: player)
+ isReconfiguringMediaSelection = true
+ mediaSelectionErrorMessage = nil
+ updateNowPlayingControlAvailability()
+ if autoplay {
+ player.pause()
+ updateNowPlaying(force: true)
+ }
+
+ mediaSelectionReconfigurationTask?.cancel()
+ mediaSelectionReconfigurationTask = Task { [weak self, weak player] in
+ guard let self,
+ let player,
+ !Task.isCancelled,
+ self.player === player,
+ self.currentRequest?.id == requestID else {
+ return
+ }
+ do {
+ let refreshedItem = try await currentStore.setSubtitleOffset(
+ streamID: selection.streamID,
+ milliseconds: milliseconds,
+ for: currentRequest.item
+ )
+ guard !Task.isCancelled,
+ self.player === player,
+ self.currentRequest?.id == requestID else {
+ return
+ }
+ guard await reportStopped(continuing: nil, time: startTime) else {
+ return
+ }
+ guard !Task.isCancelled,
+ self.player === player,
+ self.currentRequest?.id == requestID else {
+ return
+ }
+ isReconfiguringMediaSelection = false
+ updateNowPlayingControlAvailability()
+ currentStore.replacePlayback(
+ with: refreshedItem,
+ queue: currentRequest.queue,
+ source: currentPlan?.source,
+ queueSourcePreference: currentRequest.queueSourcePreference,
+ at: startTime,
+ autoplay: autoplay,
+ playbackRate: playbackRate,
+ videoQualityOverride: currentRequest.videoQualityOverride,
+ forceVideoTranscode: currentRequest.forceVideoTranscode
+ )
+ } catch is CancellationError {
+ return
+ } catch {
+ guard self.player === player,
+ self.currentRequest?.id == requestID else {
+ return
+ }
+ isReconfiguringMediaSelection = false
+ mediaSelectionErrorMessage = error.localizedDescription
+ applyAutoplay(autoplay, to: player)
+ updateNowPlayingControlAvailability()
+ updateNowPlaying(force: true)
+ }
+ }
+ }
+
+ func selectPlaybackVersion(_ mediaIndex: Int) {
+ guard let selection = playbackVersionSelection,
+ let source = selection.source(for: mediaIndex),
+ !isReconfiguringMediaSelection,
+ !isReplacingPlayback,
+ !isNavigatingQueue,
+ !isMutatingQueue,
+ let player,
+ let currentRequest,
+ let currentStore else {
+ return
+ }
+ recordUserInteraction()
+ let startTime = playbackPosition(for: player)
+ let autoplay = autoplayAfterReconfiguration(for: player)
+ let playbackRate = playbackRate(for: player)
+ isReplacingPlayback = true
+ mediaSelectionErrorMessage = nil
+ player.pause()
+ updateNowPlayingControlAvailability()
+ updateNowPlaying(force: true)
+
+ Task { [weak self, weak player] in
+ guard let self,
+ let player,
+ self.player === player,
+ self.currentRequest?.id == currentRequest.id else {
+ return
+ }
+ guard await reportStopped(continuing: nil, time: startTime) else {
+ return
+ }
+ currentStore.changePlaybackVersion(
+ to: source,
+ for: currentRequest.item,
+ queue: currentRequest.queue,
+ queueSourcePreference: currentRequest.queueSourcePreference,
+ at: startTime,
+ autoplay: autoplay,
+ playbackRate: playbackRate,
+ videoQualityOverride: currentRequest.videoQualityOverride,
+ forceVideoTranscode: currentRequest.forceVideoTranscode
+ )
+ }
+ }
+
+ func acceptQualitySuggestion() {
+ guard let suggestion = qualitySuggestion else { return }
+ recordUserInteraction()
+ qualitySuggestion = nil
+ qualitySuggestionState.recordAccepted(suggestion.targetQuality)
+ changeVideoQuality(to: suggestion.targetQuality)
+ }
+
+ func dismissQualitySuggestion() {
+ guard qualitySuggestion != nil else { return }
+ recordUserInteraction()
+ qualitySuggestion = nil
+ qualitySuggestionState.suppress()
+ }
+
+ private func changeVideoQuality(to quality: PlexVideoQuality) {
+ guard !isReconfiguringMediaSelection,
+ !isReplacingPlayback,
+ let player,
+ let currentRequest,
+ let currentStore else { return }
+ let startTime = playbackPosition(for: player)
+ let autoplay = autoplayAfterReconfiguration(for: player)
+ let playbackRate = playbackRate(for: player)
+ isReplacingPlayback = true
+ player.pause()
+ updateNowPlayingControlAvailability()
+ updateNowPlaying(force: true)
+ Task { [weak self, weak player] in
+ guard let self,
+ let player,
+ self.player === player,
+ self.currentRequest?.id == currentRequest.id else {
+ return
+ }
+ guard await reportStopped(continuing: nil, time: startTime) else {
+ return
+ }
+ currentStore.changeVideoQuality(
+ to: quality,
+ for: currentRequest.item,
+ queue: currentRequest.queue,
+ source: currentPlan?.source,
+ queueSourcePreference: currentRequest.queueSourcePreference,
+ at: startTime,
+ autoplay: autoplay,
+ playbackRate: playbackRate,
+ forceVideoTranscode: currentRequest.forceVideoTranscode
+ )
+ }
+ }
+
+ func setVideoConversionForced(_ forceVideoTranscode: Bool) {
+ guard !isReconfiguringMediaSelection,
+ !isReplacingPlayback,
+ !isNavigatingQueue,
+ !isMutatingQueue,
+ let player,
+ let currentRequest,
+ currentRequest.forceVideoTranscode != forceVideoTranscode,
+ let currentStore,
+ isVideoPlayback,
+ !forceVideoTranscode || currentStore.automaticallyAdjustVideoQuality else {
+ return
+ }
+ recordUserInteraction()
+ qualitySuggestion = nil
+ qualitySuggestionState.suppress()
+ let startTime = playbackPosition(for: player)
+ let autoplay = autoplayAfterReconfiguration(for: player)
+ let playbackRate = playbackRate(for: player)
+ isReplacingPlayback = true
+ mediaSelectionErrorMessage = nil
+ player.pause()
+ updateNowPlayingControlAvailability()
+ updateNowPlaying(force: true)
+
+ Task { [weak self, weak player] in
+ guard let self,
+ let player,
+ self.player === player,
+ self.currentRequest?.id == currentRequest.id else {
+ return
+ }
+ guard await reportStopped(continuing: nil, time: startTime) else {
+ return
+ }
+ currentStore.changeVideoConversionMode(
+ forceVideoTranscode: forceVideoTranscode,
+ videoQualityOverride: forceVideoTranscode
+ ? currentRequest.videoQualityOverride
+ : .original,
+ for: currentRequest.item,
+ queue: currentRequest.queue,
+ source: currentPlan?.source,
+ queueSourcePreference: currentRequest.queueSourcePreference,
+ at: startTime,
+ autoplay: autoplay,
+ playbackRate: playbackRate
+ )
+ }
+ }
+
+ func restartFromBeginning() {
+ guard !isReconfiguringMediaSelection,
+ !isReplacingPlayback,
+ let player,
+ let currentRequest,
+ let currentStore else { return }
+ recordUserInteraction()
+ let autoplay = autoplayAfterReconfiguration(for: player)
+ let playbackRate = playbackRate(for: player)
+ isReplacingPlayback = true
+ player.pause()
+ updateNowPlayingControlAvailability()
+ updateNowPlaying(force: true)
+ Task { [weak self, weak player] in
+ guard let self,
+ let player,
+ self.player === player,
+ self.currentRequest?.id == currentRequest.id else {
+ return
+ }
+ guard await reportStopped(continuing: nil) else {
+ return
+ }
+ currentStore.restartPlayback(
+ of: currentRequest.item,
+ queue: currentRequest.queue,
+ source: currentPlan?.source,
+ queueSourcePreference: currentRequest.queueSourcePreference,
+ autoplay: autoplay,
+ playbackRate: playbackRate,
+ videoQualityOverride: currentRequest.videoQualityOverride,
+ forceVideoTranscode: currentRequest.forceVideoTranscode
+ )
+ }
+ }
+
+ func selectAudioStream(_ streamID: Int) {
+ guard serverManagedMediaSelection.canSelectAudioStream(streamID) else { return }
+ recordUserInteraction()
+ reconfigureMediaSelection(audioStreamID: streamID)
+ }
+
+ func selectSubtitleStream(_ streamID: Int?) {
+ guard serverManagedMediaSelection.canSelectSubtitleStream(streamID) else { return }
+ recordUserInteraction()
+ reconfigureMediaSelection(subtitleStreamID: streamID ?? 0)
+ }
+
+ func dismissMediaSelectionError() {
+ recordUserInteraction()
+ mediaSelectionErrorMessage = nil
+ }
+
+ private func installObservers(
+ player: AVPlayer,
+ request: TVPlexPlaybackRequest,
+ store: TVAppStore
+ ) {
+ guard let playerItem = player.currentItem else { return }
+
+ positionObserver = player.addPeriodicTimeObserver(
+ forInterval: CMTime(seconds: 0.5, preferredTimescale: 600),
+ queue: .main
+ ) { [weak self, weak player, weak playerItem] time in
+ guard let self, let player, let playerItem else { return }
+ Task { @MainActor in
+ self.updateMarkerAction(
+ at: time.seconds,
+ player: player,
+ playerItem: playerItem,
+ request: request,
+ store: store
+ )
+ }
+ }
+
+ endObserver = NotificationCenter.default.addObserver(
+ forName: AVPlayerItem.didPlayToEndTimeNotification,
+ object: playerItem,
+ queue: .main
+ ) { [weak self, weak player, weak playerItem] _ in
+ Task { @MainActor in
+ guard let self,
+ let player,
+ let playerItem,
+ self.player === player,
+ player.currentItem === playerItem,
+ self.currentRequest?.id == request.id else {
+ return
+ }
+ await self.handlePlaybackEnded(
+ player: player,
+ installedRequest: request,
+ store: store
+ )
+ }
+ }
+
+ failedToEndObserver = NotificationCenter.default.addObserver(
+ forName: AVPlayerItem.failedToPlayToEndTimeNotification,
+ object: playerItem,
+ queue: .main
+ ) { [weak self, weak player, weak playerItem] notification in
+ let notificationMessage = (
+ notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey]
+ as? Error
+ )?.localizedDescription
+ Task { @MainActor in
+ guard let self,
+ let player,
+ let playerItem,
+ self.player === player,
+ player.currentItem === playerItem,
+ self.currentRequest?.id == request.id else {
+ return
+ }
+ self.failPlayback(
+ message: notificationMessage
+ ?? player.currentItem?.error?.localizedDescription
+ ?? "Apple TV could no longer play this Plex stream.",
+ player: player,
+ request: request,
+ store: store
+ )
+ }
+ }
+
+ timeJumpObserver = NotificationCenter.default.addObserver(
+ forName: AVPlayerItem.timeJumpedNotification,
+ object: playerItem,
+ queue: .main
+ ) { [weak self, weak player, weak playerItem] _ in
+ Task { @MainActor in
+ guard let self,
+ let player,
+ let playerItem,
+ self.player === player,
+ player.currentItem === playerItem,
+ self.currentRequest?.id == request.id else {
+ return
+ }
+ let position = player.currentTime().seconds
+ guard position.isFinite,
+ !self.expectedTimeJumps.consume(position: max(position, 0)) else {
+ return
+ }
+ self.updateNowPlaying(force: true)
+ await self.reportTimelineIfNeeded(force: true, time: position)
+ }
+ }
+
+ playbackStalledObserver = NotificationCenter.default.addObserver(
+ forName: AVPlayerItem.playbackStalledNotification,
+ object: playerItem,
+ queue: .main
+ ) { [weak self, weak player, weak playerItem] _ in
+ Task { @MainActor in
+ guard let self,
+ let player,
+ let playerItem,
+ self.player === player,
+ player.currentItem === playerItem,
+ self.currentRequest?.id == request.id else {
+ return
+ }
+ self.refreshWaitingState(for: player)
+ await self.reportTimelineIfNeeded(
+ force: true,
+ stateOverride: .buffering
+ )
+ }
+ }
+ }
+
+ private func handlePlaybackEnded(
+ player: AVPlayer,
+ installedRequest: TVPlexPlaybackRequest,
+ store: TVAppStore
+ ) async {
+ guard self.player === player,
+ let currentRequest,
+ currentRequest.id == installedRequest.id,
+ !isNavigatingQueue,
+ !isMutatingQueue,
+ !isReplacingPlayback,
+ handledEndRequestID != currentRequest.id else {
+ return
+ }
+ handledEndRequestID = currentRequest.id
+ timelineTask?.cancel()
+ timelineTask = nil
+ hasReachedEnd = true
+ failedQueueOperation = nil
+ queueNavigationErrorMessage = nil
+ updateNowPlaying(force: true)
+ markCurrentItemWatchedIfNeeded(currentRequest.item, store: store)
+ let playbackRate = playbackRate(for: player)
+
+ if store.playbackSleepTimer.stopsAtEndOfItem {
+ store.clearPlaybackSleepTimer()
+ guard await reportStopped(
+ continuing: false,
+ time: completedPlaybackTime
+ ) else { return }
+ store.dismissPlayer()
+ return
+ }
+
+ if hasRejectedContentProposal {
+ guard await reportStopped(
+ continuing: false,
+ time: completedPlaybackTime
+ ) else { return }
+ store.dismissPlayer()
+ return
+ }
+
+ let preflightAction = playbackCompletionAction(
+ for: currentRequest,
+ canAdvance: proposedNextRequest != nil
+ || currentRequest.queue?.canMoveNext == true,
+ canResetQueue: currentRequest.queue?.canRepeatAll == true,
+ store: store
+ )
+
+ if preflightAction == .replayCurrent {
+ guard await reportStopped(
+ continuing: true,
+ time: completedPlaybackTime
+ ) else { return }
+ store.restartPlayback(
+ of: currentRequest.item,
+ queue: currentRequest.queue,
+ source: currentPlan?.source,
+ queueSourcePreference: currentRequest.queueSourcePreference,
+ playbackRate: playbackRate,
+ videoQualityOverride: currentRequest.videoQualityOverride,
+ forceVideoTranscode: currentRequest.forceVideoTranscode
+ )
+ return
+ }
+
+ guard player.currentItem?.nextContentProposal == nil else {
+ return
+ }
+
+ if preflightAction == .resetQueue {
+ do {
+ let repeatedRequest = try await store.preparedRepeatedQueue(
+ from: currentRequest,
+ playbackRate: playbackRate
+ )
+ guard self.player === player,
+ self.currentRequest?.id == currentRequest.id else {
+ return
+ }
+ guard await reportStopped(
+ continuing: true,
+ time: completedPlaybackTime
+ ) else { return }
+ store.presentPlayback(
+ repeatedRequest.withPlaybackRate(playbackRate)
+ )
+ } catch is CancellationError {
+ return
+ } catch {
+ guard self.player === player,
+ self.currentRequest?.id == currentRequest.id else {
+ return
+ }
+ guard await reportStopped(
+ continuing: false,
+ time: completedPlaybackTime
+ ) else { return }
+ failedQueueOperation = .completion
+ queueNavigationErrorMessage = error.localizedDescription
+ }
+ return
+ }
+
+ do {
+ let nextRequest: TVPlexPlaybackRequest?
+ if let proposedNextRequest {
+ nextRequest = proposedNextRequest
+ } else {
+ nextRequest = try await store.preparedNextPlayback(
+ after: currentRequest,
+ playbackRate: playbackRate
+ )
+ }
+ guard self.player === player,
+ self.currentRequest?.id == currentRequest.id else {
+ return
+ }
+ let completionAction = playbackCompletionAction(
+ for: currentRequest,
+ canAdvance: nextRequest != nil,
+ canResetQueue: false,
+ store: store
+ )
+ guard let nextRequest else {
+ guard await reportStopped(
+ continuing: false,
+ time: completedPlaybackTime
+ ) else { return }
+ store.dismissPlayer()
+ return
+ }
+ guard await reportStopped(
+ continuing: true,
+ time: completedPlaybackTime
+ ) else { return }
+ switch completionAction {
+ case .advanceNext:
+ store.presentPlayback(nextRequest.withPlaybackRate(playbackRate))
+ case .presentPostPlay(let autoAdvanceAfterSeconds):
+ let presentationMode = PlexPostPlayPresentationMode.resolve(
+ action: completionAction,
+ autoplayPreferences: store.autoplayPreferences
+ )
+ startPostPlay(
+ nextRequest: nextRequest,
+ playbackRate: playbackRate,
+ autoAdvanceAfterSeconds: autoAdvanceAfterSeconds,
+ presentationMode: presentationMode,
+ store: store
+ )
+ case .stop:
+ store.dismissPlayer()
+ case .replayCurrent, .resetQueue:
+ assertionFailure("Playback completion preflight handled this action.")
+ store.dismissPlayer()
+ }
+ } catch is CancellationError {
+ return
+ } catch {
+ guard self.player === player,
+ self.currentRequest?.id == currentRequest.id else {
+ return
+ }
+ guard await reportStopped(
+ continuing: false,
+ time: completedPlaybackTime
+ ) else { return }
+ failedQueueOperation = .completion
+ queueNavigationErrorMessage = error.localizedDescription
+ }
+ }
+
+ private func startPostPlay(
+ nextRequest: TVPlexPlaybackRequest,
+ playbackRate: PlexPlaybackRate,
+ autoAdvanceAfterSeconds: Int?,
+ presentationMode: PlexPostPlayPresentationMode,
+ store: TVAppStore
+ ) {
+ postPlayTask?.cancel()
+ contentProposalEligibilityTask?.cancel()
+ contentProposalEligibilityTask = nil
+ preparedContentProposal = nil
+ authorizedContentProposals = []
+ player?.currentItem?.nextContentProposal = nil
+ upNextItem = nextRequest.item
+ upNextRequest = nextRequest
+ upNextPlaybackRate = playbackRate
+ postPlayTitle = presentationMode == .inactivityConfirmation
+ ? "Are You Still Watching?"
+ : "Up Next"
+ postPlayCountdown = autoAdvanceAfterSeconds
+ guard let autoAdvanceAfterSeconds else {
+ postPlayTask = nil
+ return
+ }
+ guard let player,
+ let requestID = currentRequest?.id else {
+ return
+ }
+ postPlayTask = Task { [weak self, weak player] in
+ for seconds in stride(from: autoAdvanceAfterSeconds, through: 1, by: -1) {
+ guard !Task.isCancelled,
+ let self,
+ let player,
+ self.player === player,
+ self.currentRequest?.id == requestID else {
+ return
+ }
+ postPlayCountdown = seconds
+ do {
+ try await Task.sleep(for: .seconds(1))
+ } catch {
+ return
+ }
+ }
+ guard !Task.isCancelled,
+ let self,
+ let player,
+ self.player === player,
+ self.currentRequest?.id == requestID else {
+ return
+ }
+ store.presentPlayback(nextRequest.withPlaybackRate(playbackRate))
+ }
+ }
+
+ private func playbackCompletionAction(
+ for request: TVPlexPlaybackRequest,
+ canAdvance: Bool,
+ canResetQueue: Bool,
+ store: TVAppStore
+ ) -> PlexPlaybackCompletionAction {
+ PlexPlaybackCompletionAction.resolve(
+ repeatMode: repeatMode,
+ canAdvance: canAdvance,
+ canResetQueue: canResetQueue,
+ completedItem: request.item,
+ mediaKind: currentPlan?.mediaKind ?? .video,
+ duration: currentPlan?.duration ?? request.item.durationSeconds,
+ autoplayPreferences: store.autoplayPreferences,
+ lastInteractionDate: userInteractionStore.lastInteractionDate,
+ isCinemaPreplayItem: request.queue?.isCurrentCinemaPreplayItem == true
+ )
+ }
+
+ private func resetPlayer() {
+ preparationEpoch.invalidate()
+ preparingRequestID = nil
+ removePlaybackStateObservations()
+ mediaSelectionInspectionTask?.cancel()
+ mediaSelectionInspectionTask = nil
+ mediaFactsInspectionTask?.cancel()
+ mediaFactsInspectionTask = nil
+ playbackMetricsTask?.cancel()
+ playbackMetricsTask = nil
+ artworkTask?.cancel()
+ artworkTask = nil
+ chapterArtworkTask?.cancel()
+ chapterArtworkTask = nil
+ contentProposalTask?.cancel()
+ contentProposalTask = nil
+ contentProposalEligibilityTask?.cancel()
+ contentProposalEligibilityTask = nil
+ inspectedMediaItemIdentifier = nil
+ deliveredMediaFacts = nil
+ playbackMetricFacts = nil
+ waitingReason = nil
+ playbackMediaKind = .video
+ audioPresentation = nil
+ playbackVersionSelection = nil
+ availableMarkerKinds = []
+ qualitySuggestion = nil
+ mediaSelectionReconfigurationTask?.cancel()
+ mediaSelectionReconfigurationTask = nil
+ queueNavigationTask?.cancel()
+ queueNavigationTask = nil
+ queueMutationTask?.cancel()
+ queueMutationTask = nil
+ rewindOnResumeTask?.cancel()
+ rewindOnResumeTask = nil
+ sleepTimerTask?.cancel()
+ sleepTimerTask = nil
+ if let rewindOnResumeTimeJumpToken {
+ expectedTimeJumps.cancel(rewindOnResumeTimeJumpToken)
+ self.rewindOnResumeTimeJumpToken = nil
+ }
+ timelineTask?.cancel()
+ timelineTask = nil
+ timelineCadence.reset()
+ postPlayTask?.cancel()
+ postPlayTask = nil
+ upNextItem = nil
+ upNextRequest = nil
+ upNextPlaybackRate = .normal
+ postPlayTitle = "Up Next"
+ proposedNextRequest = nil
+ preparedContentProposal = nil
+ authorizedContentProposals = []
+ canGoPrevious = false
+ canGoNext = false
+ previousItemTitle = nil
+ nextItemTitle = nil
+ queuePresentation = nil
+ isNavigatingQueue = false
+ isMutatingQueue = false
+ isReplacingPlayback = false
+ canChangeShuffle = false
+ isShuffled = false
+ canRepeatAll = false
+ queueNavigationErrorMessage = nil
+ failedQueueOperation = nil
+ postPlayCountdown = nil
+ activeMarkerAction = nil
+ mediaSelection = nil
+ nativeMediaSelectionState.beginReload()
+ serverManagedMediaSelection = PlexServerManagedMediaSelection()
+ isReconfiguringMediaSelection = false
+ mediaSelectionErrorMessage = nil
+ playbackInfo = nil
+ automaticMarkerTransition.reset()
+ expectedTimeJumps.invalidate()
+ if let player {
+ player.currentItem?.nowPlayingInfo = nil
+ player.pause()
+ removeObservers(from: player)
+ player.replaceCurrentItem(with: nil)
+ }
+ nowPlayingArtwork = nil
+ publishedNowPlayingItemIdentifier = nil
+ publishedNowPlayingFingerprint = nil
+ publishedNowPlayingStatus = nil
+ removeAudioSessionObservers()
+ player = nil
+ currentPlan = nil
+ playbackMethod = nil
+ selectedVideoQuality = nil
+ selectedMusicQuality = nil
+ selectedAudioBoost = nil
+ supportsAudioBoost = false
+ supportsSubtitleAutoSync = false
+ subtitleOffsetSelection = nil
+ currentRequest = nil
+ currentStore = nil
+ hasPrepared = false
+ hasMarkedCurrentItemWatched = false
+ hasReachedEnd = false
+ handledEndRequestID = nil
+ hasRejectedContentProposal = false
+ deactivateAudioSession()
+ }
+
+ private func scheduleSleepTimer(
+ _ timer: PlexPlaybackSleepTimer,
+ store: TVAppStore
+ ) {
+ sleepTimerTask?.cancel()
+ sleepTimerTask = nil
+ guard let remainingTime = timer.remainingTime(),
+ let player,
+ let requestID = currentRequest?.id else {
+ return
+ }
+
+ sleepTimerTask = Task { [weak self, weak player] in
+ if remainingTime > 0 {
+ do {
+ try await Task.sleep(for: .seconds(remainingTime))
+ } catch {
+ return
+ }
+ }
+ guard !Task.isCancelled,
+ let self,
+ let player,
+ self.player === player,
+ self.currentRequest?.id == requestID,
+ store.playbackSleepTimer == timer else {
+ return
+ }
+ await self.stopForSleepTimer(
+ player: player,
+ requestID: requestID,
+ store: store
+ )
+ }
+ }
+
+ private func stopForSleepTimer(
+ player: AVPlayer,
+ requestID: UUID,
+ store: TVAppStore
+ ) async {
+ guard self.player === player,
+ currentRequest?.id == requestID,
+ store.playbackSleepTimer.isActive else {
+ return
+ }
+ isReplacingPlayback = true
+ updateNowPlayingControlAvailability()
+ guard await reportStopped(continuing: false) else { return }
+ store.clearPlaybackSleepTimer()
+ store.dismissPlayer()
+ }
+
+ private func isCurrentPreparation(
+ _ ticket: PlexPlaybackSessionEpoch.Ticket,
+ requestID: UUID
+ ) -> Bool {
+ preparingRequestID == requestID && preparationEpoch.isCurrent(ticket)
+ }
+
+ private func checkCurrentPreparation(
+ _ ticket: PlexPlaybackSessionEpoch.Ticket,
+ requestID: UUID
+ ) throws {
+ try Task.checkCancellation()
+ guard isCurrentPreparation(ticket, requestID: requestID) else {
+ throw CancellationError()
+ }
+ }
+
+ private func prepareMediaSelection(item: PlexMediaItem, source: PlexPlaybackSource) {
+ mediaSelectionInspectionTask?.cancel()
+ nativeMediaSelectionState.beginReload()
+ mediaSelection = PlexPlaybackMediaSelection(item: item, source: source)
+ subtitleOffsetSelection = mediaSelection?.subtitleOffsetSelection
+ serverManagedMediaSelection = PlexServerManagedMediaSelection()
+ }
+
+ private func inspectNativeMediaSelection(for playerItem: AVPlayerItem) {
+ let generation = nativeMediaSelectionState.generation
+ mediaSelectionInspectionTask = Task { [weak self, weak playerItem] in
+ guard let self, let playerItem else { return }
+ do {
+ let availability = try await PlexNativeMediaInspector
+ .mediaSelectionAvailability(asset: playerItem.asset)
+ guard !Task.isCancelled,
+ self.player?.currentItem === playerItem,
+ let mediaSelection else {
+ return
+ }
+ guard nativeMediaSelectionState.accept(availability, generation: generation) else {
+ return
+ }
+ serverManagedMediaSelection = PlexServerManagedMediaSelection(
+ selection: mediaSelection,
+ nativeAvailability: availability
+ )
+ updateNowPlaying(force: true)
+ } catch {
+ return
+ }
+ }
+ }
+
+ private func reconfigureMediaSelection(
+ audioStreamID: Int? = nil,
+ subtitleStreamID: Int? = nil
+ ) {
+ guard !isReconfiguringMediaSelection,
+ audioStreamID != nil || subtitleStreamID != nil,
+ let partID = mediaSelection?.partID,
+ let player,
+ let currentRequest,
+ let currentStore else {
+ return
+ }
+
+ let requestID = currentRequest.id
+ let startTime = playbackPosition(for: player)
+ let autoplay = autoplayAfterReconfiguration(for: player)
+ let playbackRate = playbackRate(for: player)
+ isReconfiguringMediaSelection = true
+ mediaSelectionErrorMessage = nil
+ updateNowPlayingControlAvailability()
+ if autoplay {
+ player.pause()
+ updateNowPlaying(force: true)
+ }
+
+ mediaSelectionReconfigurationTask?.cancel()
+ mediaSelectionReconfigurationTask = Task { [weak self, weak player] in
+ guard let self,
+ let player,
+ !Task.isCancelled,
+ self.player === player,
+ self.currentRequest?.id == requestID else {
+ return
+ }
+ do {
+ let refreshedItem = try await currentStore.selectMediaStreams(
+ partID: partID,
+ audioStreamID: audioStreamID,
+ subtitleStreamID: subtitleStreamID,
+ for: currentRequest.item
+ )
+ guard !Task.isCancelled,
+ self.player === player,
+ self.currentRequest?.id == requestID else {
+ return
+ }
+ guard await reportStopped(continuing: nil, time: startTime) else {
+ return
+ }
+ guard !Task.isCancelled,
+ self.player === player,
+ self.currentRequest?.id == requestID else {
+ return
+ }
+ isReconfiguringMediaSelection = false
+ updateNowPlayingControlAvailability()
+ currentStore.replacePlayback(
+ with: refreshedItem,
+ queue: currentRequest.queue,
+ source: currentPlan?.source,
+ queueSourcePreference: currentRequest.queueSourcePreference,
+ at: startTime,
+ autoplay: autoplay,
+ playbackRate: playbackRate,
+ videoQualityOverride: currentRequest.videoQualityOverride,
+ forceVideoTranscode: currentRequest.forceVideoTranscode
+ )
+ } catch is CancellationError {
+ return
+ } catch {
+ guard self.player === player,
+ self.currentRequest?.id == requestID else {
+ return
+ }
+ isReconfiguringMediaSelection = false
+ mediaSelectionErrorMessage = error.localizedDescription
+ applyAutoplay(autoplay, to: player)
+ updateNowPlayingControlAvailability()
+ updateNowPlaying(force: true)
+ }
+ }
+ }
+
+ private func removeObservers(from player: AVPlayer) {
+ if let positionObserver {
+ player.removeTimeObserver(positionObserver)
+ self.positionObserver = nil
+ }
+ if let endObserver {
+ NotificationCenter.default.removeObserver(endObserver)
+ self.endObserver = nil
+ }
+ if let failedToEndObserver {
+ NotificationCenter.default.removeObserver(failedToEndObserver)
+ self.failedToEndObserver = nil
+ }
+ if let timeJumpObserver {
+ NotificationCenter.default.removeObserver(timeJumpObserver)
+ self.timeJumpObserver = nil
+ }
+ if let playbackStalledObserver {
+ NotificationCenter.default.removeObserver(playbackStalledObserver)
+ self.playbackStalledObserver = nil
+ }
+ }
+
+ private func installAudioSessionObservers(for player: AVPlayer) {
+ removeAudioSessionObservers()
+ audioInterruptionObserver = NotificationCenter.default.addObserver(
+ forName: AVAudioSession.interruptionNotification,
+ object: AVAudioSession.sharedInstance(),
+ queue: .main
+ ) { [weak self, weak player] notification in
+ guard let event = TVAudioInterruptionEvent(notification: notification) else {
+ return
+ }
+ Task { @MainActor in
+ guard let self,
+ let player,
+ self.player === player else {
+ return
+ }
+ self.handleAudioInterruption(event, player: player)
+ }
+ }
+
+ mediaServicesResetObserver = NotificationCenter.default.addObserver(
+ forName: AVAudioSession.mediaServicesWereResetNotification,
+ object: nil,
+ queue: .main
+ ) { [weak self, weak player] _ in
+ Task { @MainActor in
+ guard let self,
+ let player,
+ self.player === player,
+ let request = self.currentRequest,
+ let store = self.currentStore else {
+ return
+ }
+ self.failPlayback(
+ message: "Apple TV’s media services restarted. Try again to resume playback.",
+ player: player,
+ request: request,
+ store: store
+ )
+ }
+ }
+
+ audioRenderingModeObserver = NotificationCenter.default.addObserver(
+ forName: AVAudioSession.renderingModeChangeNotification,
+ object: AVAudioSession.sharedInstance(),
+ queue: .main
+ ) { [weak self, weak player] _ in
+ Task { @MainActor in
+ guard let self,
+ let player,
+ self.player === player else {
+ return
+ }
+ self.refreshPlaybackInfo()
+ }
+ }
+ }
+
+ private func removeAudioSessionObservers() {
+ if let audioInterruptionObserver {
+ NotificationCenter.default.removeObserver(audioInterruptionObserver)
+ self.audioInterruptionObserver = nil
+ }
+ if let mediaServicesResetObserver {
+ NotificationCenter.default.removeObserver(mediaServicesResetObserver)
+ self.mediaServicesResetObserver = nil
+ }
+ if let audioRenderingModeObserver {
+ NotificationCenter.default.removeObserver(audioRenderingModeObserver)
+ self.audioRenderingModeObserver = nil
+ }
+ wasPlayingBeforeAudioInterruption = false
+ }
+
+ private func handleAudioInterruption(
+ _ event: TVAudioInterruptionEvent,
+ player: AVPlayer
+ ) {
+ switch event {
+ case .began:
+ wasPlayingBeforeAudioInterruption = player.timeControlStatus != .paused
+ player.pause()
+ updateNowPlaying(force: true)
+ Task { await reportTimelineIfNeeded(force: true) }
+ case .ended(let systemAllowsResume):
+ let shouldResume = wasPlayingBeforeAudioInterruption
+ && systemAllowsResume
+ wasPlayingBeforeAudioInterruption = false
+ applyAutoplay(shouldResume, to: player)
+ updateNowPlaying(force: true)
+ Task { await reportTimelineIfNeeded(force: true) }
+ }
+ }
+
+ private func observePlaybackState(
+ player: AVPlayer,
+ playerItem: AVPlayerItem,
+ plan: PlexPlaybackPlan,
+ request: TVPlexPlaybackRequest,
+ store: TVAppStore
+ ) {
+ removePlaybackStateObservations()
+ needsInitialSeek = plan.startTime > 0
+ pendingInitialSeekPosition = needsInitialSeek ? plan.startTime : nil
+
+ playerStatusObservation = player.observe(
+ \.status,
+ options: [.initial, .new]
+ ) { [weak self, weak playerItem] observedPlayer, _ in
+ Task { @MainActor in
+ guard let self, let playerItem else { return }
+ self.handlePlayerStateChange(
+ player: observedPlayer,
+ playerItem: playerItem,
+ request: request,
+ store: store
+ )
+ }
+ }
+
+ playerTimeControlStatusObservation = player.observe(
+ \.timeControlStatus,
+ options: [.initial, .new]
+ ) { [weak self, weak playerItem] observedPlayer, _ in
+ Task { @MainActor in
+ guard let self, let playerItem else { return }
+ self.handlePlayerStateChange(
+ player: observedPlayer,
+ playerItem: playerItem,
+ request: request,
+ store: store
+ )
+ }
+ }
+
+ playerWaitingReasonObservation = player.observe(
+ \.reasonForWaitingToPlay,
+ options: [.initial, .new]
+ ) { [weak self, weak playerItem] observedPlayer, _ in
+ Task { @MainActor in
+ guard let self, let playerItem else { return }
+ self.handlePlayerStateChange(
+ player: observedPlayer,
+ playerItem: playerItem,
+ request: request,
+ store: store
+ )
+ }
+ }
+
+ playerDefaultRateObservation = player.observe(
+ \.defaultRate,
+ options: [.initial, .new]
+ ) { [weak self, weak playerItem] observedPlayer, _ in
+ Task { @MainActor in
+ guard let self, let playerItem else { return }
+ self.handlePlayerStateChange(
+ player: observedPlayer,
+ playerItem: playerItem,
+ request: request,
+ store: store
+ )
+ }
+ }
+
+ playerRateObservation = player.observe(
+ \.rate,
+ options: [.initial, .new]
+ ) { [weak self, weak playerItem] observedPlayer, _ in
+ Task { @MainActor in
+ guard let self, let playerItem else { return }
+ self.handlePlayerStateChange(
+ player: observedPlayer,
+ playerItem: playerItem,
+ request: request,
+ store: store,
+ forceNowPlayingUpdate: true
+ )
+ }
+ }
+
+ playerItemStatusObservation = playerItem.observe(
+ \.status,
+ options: [.initial, .new]
+ ) { [weak self, weak player] observedItem, _ in
+ Task { @MainActor in
+ guard let self, let player else { return }
+ self.handlePlayerItemStatusChange(
+ player: player,
+ playerItem: observedItem,
+ plan: plan,
+ request: request,
+ store: store
+ )
+ }
+ }
+ }
+
+ private func removePlaybackStateObservations() {
+ playerStatusObservation?.invalidate()
+ playerStatusObservation = nil
+ playerTimeControlStatusObservation?.invalidate()
+ playerTimeControlStatusObservation = nil
+ playerWaitingReasonObservation?.invalidate()
+ playerWaitingReasonObservation = nil
+ playerDefaultRateObservation?.invalidate()
+ playerDefaultRateObservation = nil
+ playerRateObservation?.invalidate()
+ playerRateObservation = nil
+ playerItemStatusObservation?.invalidate()
+ playerItemStatusObservation = nil
+ initialSeekTask?.cancel()
+ initialSeekTask = nil
+ needsInitialSeek = false
+ pendingInitialSeekPosition = nil
+ }
+
+ private func handlePlayerStateChange(
+ player: AVPlayer,
+ playerItem: AVPlayerItem,
+ request: TVPlexPlaybackRequest,
+ store: TVAppStore,
+ forceNowPlayingUpdate: Bool = false
+ ) {
+ guard self.player === player,
+ player.currentItem === playerItem,
+ currentRequest?.id == request.id else {
+ return
+ }
+ if let message = playbackFailureMessage(player: player, item: playerItem) {
+ failPlayback(
+ message: message,
+ player: player,
+ request: request,
+ store: store
+ )
+ return
+ }
+ refreshWaitingState(for: player)
+ updateNowPlaying(force: forceNowPlayingUpdate)
+ }
+
+ private func handlePlayerItemStatusChange(
+ player: AVPlayer,
+ playerItem: AVPlayerItem,
+ plan: PlexPlaybackPlan,
+ request: TVPlexPlaybackRequest,
+ store: TVAppStore
+ ) {
+ guard self.player === player,
+ player.currentItem === playerItem,
+ currentRequest?.id == request.id else {
+ return
+ }
+ if let message = playbackFailureMessage(player: player, item: playerItem) {
+ failPlayback(
+ message: message,
+ player: player,
+ request: request,
+ store: store
+ )
+ return
+ }
+
+ updateNowPlayingControlAvailability()
+ refreshWaitingState(for: player)
+ updateNowPlaying()
+
+ guard playerItem.status == .readyToPlay else { return }
+ inspectDeliveredMediaIfNeeded(for: playerItem)
+ startInitialSeekIfNeeded(
+ player: player,
+ playerItem: playerItem,
+ plan: plan,
+ request: request,
+ store: store
+ )
+ }
+
+ private func startInitialSeekIfNeeded(
+ player: AVPlayer,
+ playerItem: AVPlayerItem,
+ plan: PlexPlaybackPlan,
+ request: TVPlexPlaybackRequest,
+ store: TVAppStore
+ ) {
+ guard needsInitialSeek else { return }
+ needsInitialSeek = false
+ initialSeekTask?.cancel()
+ initialSeekTask = Task { [weak self, weak player, weak playerItem] in
+ guard let self, let player, let playerItem else { return }
+ let token = expectedTimeJumps.expect(target: plan.startTime)
+ let completed = await player.seek(
+ to: CMTime(seconds: plan.startTime, preferredTimescale: 600),
+ toleranceBefore: .zero,
+ toleranceAfter: .zero
+ )
+ guard !Task.isCancelled,
+ self.player === player,
+ player.currentItem === playerItem else {
+ expectedTimeJumps.cancel(token)
+ return
+ }
+ guard completed else {
+ expectedTimeJumps.cancel(token)
+ failPlayback(
+ message: "Apple TV could not resume this stream at the saved position.",
+ player: player,
+ request: request,
+ store: store
+ )
+ return
+ }
+ pendingInitialSeekPosition = nil
+ applyAutoplay(request.autoplay, to: player)
+ updateNowPlayingControlAvailability()
+ updateNowPlaying(force: true)
+ await reportTimelineIfNeeded(force: true, time: plan.startTime)
+ guard !Task.isCancelled, self.player === player else { return }
+ initialSeekTask = nil
+ startTimelineReporting()
+ }
+ }
+
+ private func inspectDeliveredMediaIfNeeded(for playerItem: AVPlayerItem) {
+ let identifier = ObjectIdentifier(playerItem)
+ guard inspectedMediaItemIdentifier != identifier else { return }
+ inspectedMediaItemIdentifier = identifier
+ mediaFactsInspectionTask?.cancel()
+ mediaFactsInspectionTask = Task { [weak self, weak playerItem] in
+ guard let self, let playerItem else { return }
+ let facts = await PlexNativeMediaInspector.inspect(item: playerItem)
+ guard !Task.isCancelled,
+ self.player?.currentItem === playerItem,
+ currentRequest != nil,
+ currentPlan != nil,
+ selectedVideoQuality != nil else {
+ return
+ }
+ deliveredMediaFacts = facts
+ refreshPlaybackInfo()
+ }
+ }
+
+ private func refreshWaitingState(for player: AVPlayer) {
+ let waitingReason = PlexPlaybackWaitingReason(
+ timeControlStatus: player.timeControlStatus,
+ nativeReason: player.reasonForWaitingToPlay
+ )
+ guard waitingReason != self.waitingReason else { return }
+ self.waitingReason = waitingReason
+ refreshPlaybackInfo()
+ }
+
+ private func refreshPlaybackInfo() {
+ guard let currentRequest,
+ let currentPlan,
+ let selectedVideoQuality else {
+ if playbackInfo != nil {
+ playbackInfo = nil
+ }
+ return
+ }
+ let presentation = PlexPlaybackInfoPresentation(
+ item: currentRequest.item,
+ deliveryLabel: currentPlan.method.label,
+ connectionLabel: currentStore?.connection?.kind.displayName,
+ videoQualityLabel: currentPlan.mediaKind == .video
+ ? selectedVideoQuality.label
+ : nil,
+ playbackVersionLabel: playbackVersionSelection?.selectedOption?.label,
+ queuePositionLabel: currentRequest.queue.map {
+ "\($0.presentation.currentPosition) of \($0.presentation.totalCount)"
+ },
+ waitingReasonLabel: waitingReason?.diagnosticLabel,
+ audioOutputLabel: AVAudioSession.sharedInstance().renderingMode.plexLabel,
+ deliveredMediaFacts: deliveredMediaFacts,
+ playbackMetricFacts: playbackMetricFacts?.diagnosticFacts ?? []
+ )
+ if presentation != playbackInfo {
+ playbackInfo = presentation
+ }
+ }
+
+ private func observePlaybackMetrics(
+ of playerItem: AVPlayerItem,
+ plan: PlexPlaybackPlan
+ ) {
+ playbackMetricsTask?.cancel()
+ playbackMetricFacts = nil
+ playbackMetricsTask = Task { [weak self, weak playerItem] in
+ guard let playerItem else { return }
+
+ let metrics = playerItem.metrics(forType: AVMetricPlayerItemStallEvent.self)
+ .chronologicalMerge(
+ with: playerItem.metrics(
+ forType: AVMetricPlayerItemInitialLikelyToKeepUpEvent.self
+ ),
+ playerItem.metrics(forType: AVMetricPlayerItemVariantSwitchEvent.self),
+ playerItem.metrics(forType: AVMetricHLSMediaSegmentRequestEvent.self),
+ playerItem.metrics(forType: AVMetricMediaResourceRequestEvent.self)
+ )
+ var facts = PlexPlaybackMetricFacts()
+
+ do {
+ for try await (event, publisher) in metrics {
+ guard !Task.isCancelled,
+ let self,
+ let publishedItem = publisher as? AVPlayerItem,
+ publishedItem === playerItem,
+ player?.currentItem === playerItem else {
+ return
+ }
+
+ switch event {
+ case is AVMetricPlayerItemStallEvent:
+ facts.recordStall()
+ case let event as AVMetricPlayerItemInitialLikelyToKeepUpEvent:
+ facts.recordInitialLikelyToKeepUp(
+ timeTaken: event.timeTaken,
+ variant: PlexPlaybackVariantFacts(variant: event.variant)
+ )
+ case let event as AVMetricPlayerItemVariantSwitchEvent:
+ facts.recordVariantSwitch(
+ succeeded: event.didSucceed,
+ to: PlexPlaybackVariantFacts(variant: event.toVariant)
+ )
+ case let event as AVMetricHLSMediaSegmentRequestEvent:
+ guard plan.method != .directPlay, plan.mediaKind == .video else {
+ continue
+ }
+ facts.recordBandwidthSample(
+ PlexPlaybackBandwidthSample(segment: event)
+ )
+ case let event as AVMetricMediaResourceRequestEvent:
+ guard plan.method == .directPlay, plan.mediaKind == .video else {
+ continue
+ }
+ facts.recordBandwidthSample(
+ PlexPlaybackBandwidthSample(resourceRequest: event)
+ )
+ default:
+ continue
+ }
+ updatePlaybackMetricFacts(facts)
+ }
+ } catch is CancellationError {
+ return
+ } catch {
+ return
+ }
+ }
+ }
+
+ private func updatePlaybackMetricFacts(_ facts: PlexPlaybackMetricFacts) {
+ guard facts != playbackMetricFacts else { return }
+ playbackMetricFacts = facts
+ refreshPlaybackInfo()
+ evaluateQualitySuggestion()
+ }
+
+ private func evaluateQualitySuggestion() {
+ guard qualitySuggestion == nil,
+ !qualitySuggestionState.isSuppressed,
+ let currentRequest,
+ let currentPlan,
+ let selectedVideoQuality,
+ let currentStore else {
+ return
+ }
+ let selection = PlexVideoQualitySelection(
+ selectedQuality: selectedVideoQuality,
+ isVideo: currentPlan.mediaKind == .video,
+ canChange: !isReconfiguringMediaSelection
+ )
+ let sourceBitrate = currentRequest.item.media.indices.contains(
+ currentPlan.source.mediaIndex
+ ) ? currentRequest.item.media[currentPlan.source.mediaIndex].bitrate : nil
+ qualitySuggestion = PlexPlaybackQualitySuggestionPolicy.suggestion(
+ isEnabled: currentStore.qualitySuggestionsEnabled
+ && !currentStore.automaticallyAdjustVideoQuality,
+ selection: selection,
+ sourceBitrate: sourceBitrate,
+ maximumQuality: currentStore.activeVideoQuality,
+ isTranscoding: currentPlan.method == .transcode,
+ metrics: playbackMetricFacts,
+ excludedQualities: qualitySuggestionState.acceptedQualities
+ )
+ }
+
+ private func loadArtwork(
+ for item: PlexMediaItem,
+ playerItem: AVPlayerItem,
+ store: TVAppStore
+ ) {
+ artworkTask?.cancel()
+ artworkTask = Task { [weak self, weak playerItem] in
+ guard let self,
+ let playerItem,
+ let data = await store.playbackArtworkData(for: item),
+ !Task.isCancelled,
+ self.player?.currentItem === playerItem else {
+ return
+ }
+ let artwork = AVMutableMetadataItem()
+ artwork.identifier = .commonIdentifierArtwork
+ artwork.value = data as NSData
+ playerItem.externalMetadata = playerItem.externalMetadata.filter {
+ $0.identifier != .commonIdentifierArtwork
+ } + [artwork]
+ if let image = await PlexImageDecoder.decodeCGImage(
+ from: data,
+ maximumPixelSize: 1_200
+ )?.image,
+ !Task.isCancelled,
+ self.player?.currentItem === playerItem {
+ nowPlayingArtwork = PlexNowPlayingArtworkFactory.make(from: image)
+ updateNowPlaying(force: true)
+ }
+ }
+ }
+
+ private func loadChapterArtwork(
+ for item: PlexMediaItem,
+ playerItem: AVPlayerItem,
+ store: TVAppStore
+ ) {
+ chapterArtworkTask?.cancel()
+ let chapters = PlexPlaybackChapter.chapters(
+ from: item.chapters,
+ mediaDurationMilliseconds: item.duration
+ )
+ guard chapters.contains(where: { $0.thumbnailPath != nil }) else {
+ chapterArtworkTask = nil
+ return
+ }
+
+ chapterArtworkTask = Task { [weak self, weak playerItem] in
+ guard let self, let playerItem else { return }
+ let artwork = await store.playbackChapterArtwork(for: chapters)
+ guard !Task.isCancelled,
+ !artwork.isEmpty,
+ self.player?.currentItem === playerItem else {
+ return
+ }
+ playerItem.navigationMarkerGroups = navigationMarkerGroups(
+ for: chapters,
+ artworkByChapterID: artwork
+ )
+ }
+ }
+
+ private func prepareNextItem(
+ for request: TVPlexPlaybackRequest,
+ playerItem: AVPlayerItem,
+ store: TVAppStore
+ ) {
+ contentProposalTask?.cancel()
+ contentProposalEligibilityTask?.cancel()
+ contentProposalEligibilityTask = nil
+ preparedContentProposal = nil
+ authorizedContentProposals = []
+ playerItem.nextContentProposal = nil
+ guard repeatMode != .one else {
+ proposedNextRequest = nil
+ return
+ }
+ contentProposalTask = Task { [weak self, weak playerItem] in
+ guard let self,
+ let playerItem,
+ let nextRequest = try? await store.preparedNextPlayback(
+ after: request,
+ playbackRate: request.playbackRate
+ ),
+ !Task.isCancelled,
+ self.player?.currentItem === playerItem,
+ self.currentRequest?.id == request.id else {
+ return
+ }
+
+ proposedNextRequest = nextRequest
+ canGoNext = true
+ nextItemTitle = nextRequest.item.title
+
+ let transitionTime = contentProposalTransitionTime(for: request.item)
+ let proposalMetadata = metadata(for: nextRequest.item)
+ let proposal = contentProposal(
+ for: nextRequest.item,
+ transitionTime: transitionTime,
+ metadata: proposalMetadata,
+ previewImage: nil
+ )
+ preparedContentProposal = proposal
+ authorizedContentProposals = [proposal]
+ refreshContentProposalEligibility()
+
+ let artworkData = await store.contentProposalArtworkData(for: nextRequest.item)
+ guard !Task.isCancelled,
+ self.player?.currentItem === playerItem,
+ self.currentRequest?.id == request.id,
+ preparedContentProposal === proposal else {
+ return
+ }
+ guard let artworkData,
+ let image = await PlexImageDecoder.decodeCGImage(
+ from: artworkData,
+ maximumPixelSize: 1_280
+ )?.image else {
+ return
+ }
+ guard !Task.isCancelled,
+ self.player?.currentItem === playerItem,
+ self.currentRequest?.id == request.id,
+ preparedContentProposal === proposal,
+ canEnrichContentProposal(
+ transitionTime: transitionTime,
+ playerTime: playerItem.currentTime()
+ ) else {
+ return
+ }
+
+ let enrichedProposal = contentProposal(
+ for: nextRequest.item,
+ transitionTime: transitionTime,
+ metadata: proposalMetadata,
+ previewImage: UIImage(cgImage: image)
+ )
+ preparedContentProposal = enrichedProposal
+ authorizedContentProposals.append(enrichedProposal)
+ refreshContentProposalEligibility()
+ }
+ }
+
+ private func isAuthorizedContentProposal(_ proposal: AVContentProposal) -> Bool {
+ proposedNextRequest != nil
+ && authorizedContentProposals.contains { $0 === proposal }
+ }
+
+ private func contentProposalTransitionTime(for item: PlexMediaItem) -> CMTime {
+ let duration = item.duration.map { TimeInterval($0) / 1_000 }
+ return PlexPlaybackMarkerAction.creditsStartTime(
+ in: item.markers,
+ duration: duration
+ ).map {
+ CMTime(seconds: $0, preferredTimescale: 600)
+ } ?? .indefinite
+ }
+
+ private func contentProposal(
+ for item: PlexMediaItem,
+ transitionTime: CMTime,
+ metadata: [AVMetadataItem],
+ previewImage: UIImage?
+ ) -> AVContentProposal {
+ let proposal = AVContentProposal(
+ contentTimeForTransition: transitionTime,
+ title: item.title,
+ previewImage: previewImage
+ )
+ proposal.metadata = metadata
+ return proposal
+ }
+
+ private func canEnrichContentProposal(
+ transitionTime: CMTime,
+ playerTime: CMTime
+ ) -> Bool {
+ let transitionSeconds = transitionTime.seconds
+ let playerSeconds = playerTime.seconds
+ return !transitionSeconds.isFinite
+ || !playerSeconds.isFinite
+ || playerSeconds < transitionSeconds
+ }
+
+ private func refreshContentProposalEligibility() {
+ contentProposalEligibilityTask?.cancel()
+ contentProposalEligibilityTask = nil
+ guard let playerItem = player?.currentItem,
+ let currentRequest,
+ let currentStore,
+ let preparedContentProposal else {
+ return
+ }
+
+ let action = playbackCompletionAction(
+ for: currentRequest,
+ canAdvance: true,
+ canResetQueue: false,
+ store: currentStore
+ )
+ let presentationMode = PlexPostPlayPresentationMode.resolve(
+ action: action,
+ autoplayPreferences: currentStore.autoplayPreferences
+ )
+ switch presentationMode {
+ case .manual:
+ preparedContentProposal.automaticAcceptanceInterval = .nan
+ playerItem.nextContentProposal = preparedContentProposal
+ case .automatic(let autoAdvanceAfterSeconds):
+ preparedContentProposal.automaticAcceptanceInterval = TimeInterval(
+ autoAdvanceAfterSeconds
+ )
+ playerItem.nextContentProposal = preparedContentProposal
+ case .none, .inactivityConfirmation:
+ playerItem.nextContentProposal = nil
+ }
+
+ let preferences = currentStore.autoplayPreferences
+ let duration = currentPlan?.duration ?? currentRequest.item.durationSeconds
+ guard preferences.isEnabled,
+ let passoutInterval = preferences.passoutProtection.interval,
+ duration > 20 * 60 else {
+ return
+ }
+ let remaining = passoutInterval
+ - Date().timeIntervalSince(userInteractionStore.lastInteractionDate)
+ guard remaining > 0 else { return }
+ let requestID = currentRequest.id
+ contentProposalEligibilityTask = Task { [weak self, weak playerItem] in
+ try? await Task.sleep(for: .seconds(remaining + 0.1))
+ guard !Task.isCancelled,
+ let self,
+ let playerItem,
+ self.player?.currentItem === playerItem,
+ self.currentRequest?.id == requestID else {
+ return
+ }
+ refreshContentProposalEligibility()
+ }
+ }
+
+ private func markCurrentItemWatchedIfNeeded(
+ _ item: PlexMediaItem,
+ store: TVAppStore
+ ) {
+ guard !hasMarkedCurrentItemWatched else { return }
+ hasMarkedCurrentItemWatched = true
+ Task { await store.markWatched(item) }
+ }
+
+ private func playbackFailureMessage(player: AVPlayer, item: AVPlayerItem) -> String? {
+ if item.status == .failed {
+ return item.error?.localizedDescription ?? "Apple TV could not read this Plex stream."
+ }
+ if player.status == .failed {
+ return player.error?.localizedDescription ?? "Apple TV could no longer play this stream."
+ }
+ return nil
+ }
+
+ private func configureAudioSession(for mediaKind: PlexPlaybackMediaKind) throws {
+ let audioSession = AVAudioSession.sharedInstance()
+ try audioSession.setCategory(
+ .playback,
+ mode: mediaKind == .video ? .moviePlayback : .default,
+ policy: .longFormAudio
+ )
+ try audioSession.setSupportsMultichannelContent(true)
+ try audioSession.setActive(true)
+ let maximumOutputChannels = audioSession.maximumOutputNumberOfChannels
+ if maximumOutputChannels > 0 {
+ try audioSession.setPreferredOutputNumberOfChannels(maximumOutputChannels)
+ }
+ }
+
+ private func deactivateAudioSession() {
+ try? AVAudioSession.sharedInstance().setActive(
+ false,
+ options: .notifyOthersOnDeactivation
+ )
+ }
+
+ private func activateNowPlaying() {
+ publishedNowPlayingItemIdentifier = nil
+ publishedNowPlayingFingerprint = nil
+ publishedNowPlayingStatus = nil
+ updateNowPlaying(force: true)
+ }
+
+ private func updateNowPlaying(force: Bool = false) {
+ guard let playerItem = player?.currentItem,
+ let metadata = nowPlayingMetadata else {
+ return
+ }
+ let itemIdentifier = ObjectIdentifier(playerItem)
+ let status = nowPlayingStatus
+ guard force
+ || itemIdentifier != publishedNowPlayingItemIdentifier
+ || metadata.publicationFingerprint != publishedNowPlayingFingerprint
+ || status != publishedNowPlayingStatus else {
+ return
+ }
+
+ // AVPlayerViewController owns the tvOS Now Playing session. Supplying
+ // item metadata enriches that native session without registering a
+ // competing app-global info or remote-command center.
+ playerItem.nowPlayingInfo = metadata.nowPlayingInfo(
+ artwork: nowPlayingArtwork
+ )
+ publishedNowPlayingItemIdentifier = itemIdentifier
+ publishedNowPlayingFingerprint = metadata.publicationFingerprint
+ publishedNowPlayingStatus = status
+ }
+
+ private func updateNowPlayingControlAvailability() {
+ // AVKit owns transport availability on tvOS. This inexpensive refresh
+ // still republishes when an authoritative queue change altered item
+ // metadata; ordinary busy-state changes are filtered by the fingerprint.
+ updateNowPlaying()
+ }
+
+ private func resumePlaybackWithRewind(_ player: AVPlayer) {
+ guard let action = PlexRewindOnResumePolicy.action(
+ status: nowPlayingStatus,
+ position: player.currentTime().seconds,
+ preference: currentStore?.rewindOnResume ?? .none
+ ) else {
+ return
+ }
+
+ switch action {
+ case .playImmediately:
+ player.play()
+ refreshWaitingState(for: player)
+ updateNowPlaying(force: true)
+ Task { await reportTimelineIfNeeded(force: true) }
+
+ case .seekThenPlay(let target):
+ cancelPendingRewindOnResume()
+ guard let playerItem = player.currentItem,
+ playerItem.status == .readyToPlay else {
+ player.play()
+ refreshWaitingState(for: player)
+ updateNowPlaying(force: true)
+ Task { await reportTimelineIfNeeded(force: true) }
+ return
+ }
+
+ let timeJumpToken = expectedTimeJumps.expect(target: target)
+ rewindOnResumeTimeJumpToken = timeJumpToken
+ rewindOnResumeTask = Task { [weak self, weak player, weak playerItem] in
+ guard let self, let player, let playerItem else { return }
+ let completed = await player.seek(
+ to: CMTime(seconds: target, preferredTimescale: 600),
+ toleranceBefore: .zero,
+ toleranceAfter: .zero
+ )
+ guard !Task.isCancelled,
+ self.player === player,
+ player.currentItem === playerItem,
+ self.rewindOnResumeTimeJumpToken == timeJumpToken else {
+ self.expectedTimeJumps.cancel(timeJumpToken)
+ return
+ }
+
+ self.rewindOnResumeTask = nil
+ self.rewindOnResumeTimeJumpToken = nil
+ if !completed {
+ self.expectedTimeJumps.cancel(timeJumpToken)
+ }
+ player.play()
+ self.refreshWaitingState(for: player)
+ self.updateNowPlayingControlAvailability()
+ self.updateNowPlaying(force: true)
+ await self.reportTimelineIfNeeded(force: true)
+ }
+ updateNowPlayingControlAvailability()
+ }
+ }
+
+ private func cancelPendingRewindOnResume() {
+ guard rewindOnResumeTask != nil || rewindOnResumeTimeJumpToken != nil else {
+ return
+ }
+ rewindOnResumeTask?.cancel()
+ rewindOnResumeTask = nil
+ player?.currentItem?.cancelPendingSeeks()
+ expectedTimeJumps.cancel(rewindOnResumeTimeJumpToken)
+ rewindOnResumeTimeJumpToken = nil
+ updateNowPlayingControlAvailability()
+ }
+
+ private var nowPlayingMetadata: PlexNowPlayingMetadata? {
+ guard let currentRequest else { return nil }
+ let queue = currentRequest.queue?.presentation
+ let position = player.map { playbackPosition(for: $0) } ?? 0
+ return PlexNowPlayingMetadata(
+ item: currentRequest.item,
+ duration: currentPlan?.duration ?? currentRequest.item.durationSeconds,
+ elapsedTime: position.isFinite ? max(position, 0) : 0,
+ playbackRate: nowPlayingStatus == .playing ? Double(player?.rate ?? 0) : 0,
+ defaultPlaybackRate: Double(player?.defaultRate ?? 1),
+ serverIdentifier: currentStore?.connection?.serverIdentifier,
+ queuePosition: queue?.currentPosition,
+ queueCount: queue?.totalCount
+ )
+ }
+
+ private var nowPlayingStatus: PlexPlaybackStatus {
+ guard let player else { return .idle }
+ if hasReachedEnd {
+ return .ended
+ }
+ if let playerItem = player.currentItem,
+ let failure = playbackFailureMessage(player: player, item: playerItem) {
+ return .failed(failure)
+ }
+ switch player.timeControlStatus {
+ case .playing:
+ return .playing
+ case .waitingToPlayAtSpecifiedRate:
+ return player.currentItem?.status == .readyToPlay ? .buffering : .preparing
+ case .paused:
+ return player.currentItem?.status == .readyToPlay ? .paused : .preparing
+ @unknown default:
+ return .preparing
+ }
+ }
+
+ private var isPlaybackControlBusy: Bool {
+ isReconfiguringMediaSelection
+ || isNavigatingQueue
+ || isMutatingQueue
+ || isReplacingPlayback
+ || rewindOnResumeTask != nil
+ || isPreparingInitialPosition
+ }
+
+ private func applyAutoplay(_ autoplay: Bool, to player: AVPlayer) {
+ // Only the completed initial seek may release pending resume playback.
+ // Error recovery and audio interruptions must not start at zero first.
+ if autoplay && !isPreparingInitialPosition {
+ player.play()
+ } else {
+ player.pause()
+ }
+ }
+
+ private func playbackRate(for player: AVPlayer) -> PlexPlaybackRate {
+ PlexPlaybackRate(remoteCommandValue: player.defaultRate) ?? .normal
+ }
+
+ private func failPlayback(
+ message: String,
+ player: AVPlayer,
+ request: TVPlexPlaybackRequest,
+ store: TVAppStore
+ ) {
+ let time = playbackPosition(for: player)
+ let playbackRate = playbackRate(for: player)
+ let recovery = currentPlan.map {
+ PlexPlaybackRecoveryRequest(
+ plan: $0,
+ videoQuality: selectedVideoQuality ?? store.activeVideoQuality,
+ position: time
+ )
+ }
+ stoppedTimelineSessionIdentifier = request.sessionIdentifier
+ stoppedTimelineContinuing = nil
+ resetPlayer()
+ pendingRecovery = recovery.map { (request.id, $0, playbackRate) }
+ canRetryPlayback = true
+ errorMessage = message
+ Task {
+ _ = await reportPlayback(
+ of: request,
+ state: .stopped,
+ time: time.isFinite ? time : 0,
+ store: store
+ )
+ }
+ }
+
+ private func startTimelineReporting() {
+ timelineTask?.cancel()
+ timelineTask = Task { [weak self] in
+ while !Task.isCancelled {
+ guard let self else { return }
+ await reportTimelineIfNeeded()
+ try? await Task.sleep(for: .seconds(1))
+ }
+ }
+ }
+
+ private func reportTimelineIfNeeded(
+ force: Bool = false,
+ time: TimeInterval? = nil,
+ stateOverride: PlexTimelineState? = nil
+ ) async {
+ guard let player, let currentRequest, let currentStore else { return }
+ let state = stateOverride ?? timelineState(for: player)
+ let instant = ContinuousClock.now
+ guard force || timelineCadence.shouldReport(state: state, at: instant) else { return }
+ let position = pendingInitialSeekPosition ?? time ?? playbackPosition(for: player)
+ let response = await reportPlayback(
+ of: currentRequest,
+ state: state,
+ time: position,
+ store: currentStore
+ )
+ guard self.player === player,
+ self.currentRequest?.id == currentRequest.id else { return }
+ if state != .stopped, let termination = response?.termination {
+ terminatePlayback(termination)
+ return
+ }
+ timelineCadence.record(state: state, at: instant)
+ }
+
+ private func reportStopped(
+ continuing: Bool?,
+ time: TimeInterval? = nil
+ ) async -> Bool {
+ guard let player, let currentRequest, let currentStore else { return false }
+ let sessionIdentifier = currentRequest.sessionIdentifier
+ let alreadyReported = stoppedTimelineSessionIdentifier == sessionIdentifier
+ && stoppedTimelineContinuing == continuing
+ player.pause()
+ timelineTask?.cancel()
+ timelineTask = nil
+ if !alreadyReported {
+ stoppedTimelineSessionIdentifier = sessionIdentifier
+ stoppedTimelineContinuing = continuing
+ let position = pendingInitialSeekPosition ?? time ?? playbackPosition(for: player)
+ _ = await reportPlayback(
+ of: currentRequest,
+ state: .stopped,
+ time: position.isFinite ? position : 0,
+ continuing: continuing,
+ store: currentStore
+ )
+ }
+ return self.player === player
+ && self.currentRequest?.sessionIdentifier == sessionIdentifier
+ }
+
+ private var completedPlaybackTime: TimeInterval {
+ if let duration = currentRequest?.item.duration {
+ return TimeInterval(max(duration, 0)) / 1_000
+ }
+ guard let position = player?.currentTime().seconds, position.isFinite else {
+ return 0
+ }
+ return max(position, 0)
+ }
+
+ private func playbackPosition(for player: AVPlayer) -> TimeInterval {
+ // AVPlayer reports zero while loading and seeking to the saved position.
+ // That temporary value must not overwrite Plex progress or a retry offset.
+ if let pendingInitialSeekPosition { return pendingInitialSeekPosition }
+ let position = player.currentTime().seconds
+ return position.isFinite ? max(position, 0) : 0
+ }
+
+ private func autoplayAfterReconfiguration(for player: AVPlayer) -> Bool {
+ // Initial resume deliberately pauses AVPlayer until its seek completes.
+ // Replacing that stream must preserve the requested playback intent.
+ if isPreparingInitialPosition, let currentRequest {
+ return currentRequest.autoplay
+ }
+ return player.timeControlStatus != .paused
+ }
+
+ private func reportPlayback(
+ of request: TVPlexPlaybackRequest,
+ state: PlexTimelineState,
+ time: TimeInterval,
+ continuing: Bool? = nil,
+ store: TVAppStore
+ ) async -> PlexTimelineResponse? {
+ let time = time.isFinite ? max(time, 0) : 0
+ let update = PlexTimelineUpdate(
+ ratingKey: request.item.ratingKey,
+ state: state,
+ time: Int(time * 1_000),
+ duration: request.item.duration ?? 0,
+ sessionIdentifier: request.sessionIdentifier,
+ playQueueItemID: request.queue?.currentItem.playQueueItemID
+ ?? request.item.playQueueItemID,
+ continuing: continuing
+ )
+ return await timelineReporter(for: store).report(update)
+ }
+
+ private func timelineReporter(for store: TVAppStore) -> PlexTimelineReportSequencer {
+ if let timelineReporter {
+ return timelineReporter
+ }
+ let reporter = PlexTimelineReportSequencer { [store] update in
+ await store.reportPlayback(update)
+ }
+ timelineReporter = reporter
+ return reporter
+ }
+
+ private func terminatePlayback(_ termination: PlexTimelineResponse.Termination) {
+ resetPlayer()
+ pendingRecovery = nil
+ canRetryPlayback = false
+ errorMessage = termination.message
+ }
+
+ private func timelineState(for player: AVPlayer) -> PlexTimelineState {
+ switch player.timeControlStatus {
+ case .paused:
+ .paused
+ case .waitingToPlayAtSpecifiedRate:
+ .buffering
+ case .playing:
+ .playing
+ @unknown default:
+ .buffering
+ }
+ }
+
+ private func updateMarkerAction(
+ at position: TimeInterval,
+ player: AVPlayer,
+ playerItem: AVPlayerItem,
+ request: TVPlexPlaybackRequest,
+ store: TVAppStore
+ ) {
+ guard self.player === player,
+ player.currentItem === playerItem,
+ currentRequest?.id == request.id else {
+ return
+ }
+ let duration = request.item.duration.map { TimeInterval($0) / 1_000 }
+ let action = PlexPlaybackMarkerAction.active(
+ in: request.item.markers,
+ at: position,
+ duration: duration
+ )
+ let preferences = store.playbackMarkerPreferences
+
+ let manualAction = PlexPlaybackMarkerAction.manual(
+ in: request.item.markers,
+ at: position,
+ duration: duration,
+ preferences: preferences
+ )
+ if manualAction != activeMarkerAction {
+ activeMarkerAction = manualAction
+ }
+
+ guard let automaticAction = automaticMarkerTransition.action(
+ for: action,
+ preferences: preferences
+ ) else {
+ return
+ }
+
+ let timeJumpToken = expectedTimeJumps.expect(target: automaticAction.targetTime)
+ player.seek(
+ to: CMTime(seconds: automaticAction.targetTime, preferredTimescale: 600),
+ toleranceBefore: .zero,
+ toleranceAfter: .zero
+ ) { [weak self, weak player, weak playerItem] completed in
+ Task { @MainActor in
+ guard let self else { return }
+ guard let player,
+ let playerItem,
+ self.player === player,
+ player.currentItem === playerItem,
+ self.currentRequest?.id == request.id else {
+ self.expectedTimeJumps.cancel(timeJumpToken)
+ return
+ }
+ if completed {
+ await self.reportTimelineIfNeeded(
+ force: true,
+ time: automaticAction.targetTime
+ )
+ } else {
+ self.expectedTimeJumps.cancel(timeJumpToken)
+ self.automaticMarkerTransition.retry(automaticAction)
+ }
+ }
+ }
+ }
+
+ func playbackMarkerPreferencesDidChange(
+ _ preferences: PlexPlaybackMarkerPreferences,
+ store: TVAppStore
+ ) {
+ guard let player,
+ let playerItem = player.currentItem,
+ let currentRequest else {
+ return
+ }
+ updateMarkerAction(
+ at: player.currentTime().seconds,
+ player: player,
+ playerItem: playerItem,
+ request: currentRequest,
+ store: store
+ )
+ }
+
+ private func metadata(for item: PlexMediaItem) -> [AVMetadataItem] {
+ let presentation = PlexPlayerPlaybackInfoPresentation(item: item)
+ var values = [
+ metadataItem(identifier: .commonIdentifierTitle, value: presentation.title),
+ ]
+ if let hierarchyLine = presentation.hierarchyLine {
+ values.append(metadataItem(
+ identifier: .iTunesMetadataTrackSubTitle,
+ value: hierarchyLine
+ ))
+ }
+ if let summary = presentation.summary {
+ values.append(metadataItem(identifier: .commonIdentifierDescription, value: summary))
+ }
+ if let contentRating = presentation.contentRating {
+ values.append(metadataItem(
+ identifier: .iTunesMetadataContentRating,
+ value: contentRating
+ ))
+ }
+ if let genre = presentation.genre {
+ values.append(metadataItem(identifier: .quickTimeMetadataGenre, value: genre))
+ }
+ return values
+ }
+
+ private func navigationMarkerGroups(for item: PlexMediaItem) -> [AVNavigationMarkersGroup] {
+ let chapters = PlexPlaybackChapter.chapters(
+ from: item.chapters,
+ mediaDurationMilliseconds: item.duration
+ )
+ return navigationMarkerGroups(for: chapters)
+ }
+
+ private func navigationMarkerGroups(
+ for chapters: [PlexPlaybackChapter],
+ artworkByChapterID: [String: Data] = [:]
+ ) -> [AVNavigationMarkersGroup] {
+ guard !chapters.isEmpty else { return [] }
+
+ let markers = chapters.map { chapter in
+ var metadata = [
+ metadataItem(identifier: .commonIdentifierTitle, value: chapter.title)
+ ]
+ if let artwork = artworkByChapterID[chapter.id] {
+ metadata.append(artworkMetadataItem(artwork))
+ }
+ return AVTimedMetadataGroup(
+ items: metadata,
+ timeRange: CMTimeRange(
+ start: CMTime(seconds: chapter.startTime, preferredTimescale: 600),
+ duration: CMTime(seconds: chapter.duration, preferredTimescale: 600)
+ )
+ )
+ }
+ return [AVNavigationMarkersGroup(title: nil, timedNavigationMarkers: markers)]
+ }
+
+ private func interstitialTimeRanges(for item: PlexMediaItem) -> [AVInterstitialTimeRange] {
+ PlexPlaybackInterstitial.commercials(
+ in: item.markers,
+ duration: item.durationSeconds
+ )
+ .map { interstitial in
+ AVInterstitialTimeRange(
+ timeRange: CMTimeRange(
+ start: CMTime(
+ seconds: interstitial.startTime,
+ preferredTimescale: 600
+ ),
+ duration: CMTime(
+ seconds: interstitial.duration,
+ preferredTimescale: 600
+ )
+ )
+ )
+ }
+ }
+
+ private func metadataItem(identifier: AVMetadataIdentifier, value: String) -> AVMetadataItem {
+ let item = AVMutableMetadataItem()
+ item.identifier = identifier
+ item.value = value as NSString
+ item.extendedLanguageTag = "und"
+ return item.copy() as? AVMetadataItem ?? item
+ }
+
+ private func artworkMetadataItem(_ data: Data) -> AVMetadataItem {
+ let item = AVMutableMetadataItem()
+ item.identifier = .commonIdentifierArtwork
+ item.value = data as NSData
+ item.extendedLanguageTag = "und"
+ return item.copy() as? AVMetadataItem ?? item
+ }
+}
+
+private enum TVPlaybackQueueDestination: Sendable {
+ case adjacent(PlexPlaybackQueueDirection)
+ case item(String)
+}
+
+private enum TVPlaybackQueueMutation: Sendable {
+ case shuffled(Bool)
+ case move(String, PlexPlayQueueItemMoveDirection)
+ case remove(String)
+}
+
+private enum TVPlaybackQueueFailureRecovery: Sendable {
+ case navigation(TVPlaybackQueueDestination)
+ case mutation(TVPlaybackQueueMutation)
+ case completion
+}
+
+private enum TVAudioInterruptionEvent: Sendable {
+ case began
+ case ended(systemAllowsResume: Bool)
+
+ init?(notification: Notification) {
+ guard let typeNumber = notification.userInfo?[
+ AVAudioSessionInterruptionTypeKey
+ ] as? NSNumber,
+ let type = AVAudioSession.InterruptionType(
+ rawValue: typeNumber.uintValue
+ ) else {
+ return nil
+ }
+
+ switch type {
+ case .began:
+ self = .began
+ case .ended:
+ let optionsNumber = notification.userInfo?[
+ AVAudioSessionInterruptionOptionKey
+ ] as? NSNumber
+ let options = AVAudioSession.InterruptionOptions(
+ rawValue: optionsNumber?.uintValue ?? 0
+ )
+ self = .ended(systemAllowsResume: options.contains(.shouldResume))
+ @unknown default:
+ return nil
+ }
+ }
+}
+
+private extension AVAudioSession.RenderingMode {
+ var plexLabel: String? {
+ switch self {
+ case .notApplicable:
+ nil
+ case .monoStereo:
+ "Mono / Stereo"
+ case .surround:
+ "Surround"
+ case .spatialAudio:
+ "Spatial Audio"
+ case .dolbyAudio:
+ "Dolby Audio"
+ case .dolbyAtmos:
+ "Dolby Atmos"
+ @unknown default:
+ nil
+ }
+ }
+}
+
+private struct TVPostPlayOverlay: View {
+ private enum FocusedAction: Hashable {
+ case playNow
+ case notNow
+ }
+
+ let item: PlexMediaItem
+ let title: String
+ let countdown: Int?
+ let playNow: () -> Void
+ let cancel: () -> Void
+
+ @Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion
+ @FocusState private var focusedAction: FocusedAction?
+
+ var body: some View {
+ ZStack(alignment: .bottomLeading) {
+ TVPlexArtwork(
+ path: item.preferredBackdropPath,
+ width: 1920,
+ height: 1080,
+ systemImage: item.type?.lowercased() == "track" ? "music.note" : "film"
+ )
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .overlay {
+ LinearGradient(
+ stops: [
+ .init(color: .black.opacity(0.18), location: 0),
+ .init(color: .black.opacity(0.58), location: 0.52),
+ .init(color: .black.opacity(0.96), location: 1)
+ ],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ }
+ .overlay {
+ LinearGradient(
+ colors: [.black.opacity(0.88), .black.opacity(0.28), .clear],
+ startPoint: .leading,
+ endPoint: .trailing
+ )
+ }
+ .accessibilityHidden(true)
+
+ HStack(alignment: .bottom, spacing: 60) {
+ VStack(alignment: .leading, spacing: 18) {
+ Label(
+ title.uppercased(),
+ systemImage: title == "Up Next"
+ ? "play.square.stack"
+ : "person.fill.checkmark"
+ )
+ .font(.headline.bold())
+ .tracking(2)
+ .foregroundStyle(TVTheme.plexGold)
+
+ if let context = item.contextTitle {
+ Text(context)
+ .font(.title2.weight(.semibold))
+ .foregroundStyle(.white.opacity(0.76))
+ }
+
+ Text(item.title)
+ .font(.largeTitle.bold())
+ .lineLimit(2)
+
+ if !item.metadataLine.isEmpty {
+ Text(item.metadataLine)
+ .font(.title3.weight(.semibold))
+ .foregroundStyle(.white.opacity(0.72))
+ }
+
+ if let summary = item.summary?.nilIfBlank {
+ Text(summary)
+ .font(.body)
+ .foregroundStyle(.white.opacity(0.84))
+ .lineLimit(3)
+ }
+
+ playbackStatus
+ .padding(.top, 6)
+
+ HStack(spacing: 24) {
+ Button("Play Now", systemImage: "play.fill", action: playNow)
+ .buttonStyle(.borderedProminent)
+ .tint(TVTheme.plexGold)
+ .focused($focusedAction, equals: .playNow)
+ Button("Not Now", role: .cancel, action: cancel)
+ .focused($focusedAction, equals: .notNow)
+ }
+ .defaultFocus($focusedAction, .playNow)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ TVPlexArtwork(
+ path: item.preferredBackdropPath,
+ width: 1120,
+ height: 630,
+ systemImage: item.type?.lowercased() == "track" ? "music.note" : "film"
+ )
+ .aspectRatio(16.0 / 9.0, contentMode: .fit)
+ .containerRelativeFrame(.horizontal, count: 3, spacing: 40)
+ .clipShape(.rect(cornerRadius: 12))
+ .accessibilityHidden(true)
+ }
+ .safeAreaPadding()
+ }
+ .background(Color.black)
+ .ignoresSafeArea()
+ .accessibilityElement(children: .contain)
+ }
+
+ @ViewBuilder
+ private var playbackStatus: some View {
+ if let countdown {
+ Label("Playing automatically in \(countdown) seconds", systemImage: "timer")
+ .contentTransition(.numericText(value: Double(countdown)))
+ .animation(
+ PlexMotion.contentReplacementAnimation(
+ reduceMotion: accessibilityReduceMotion
+ ),
+ value: countdown
+ )
+ } else {
+ Label("Select Play Now to continue watching", systemImage: "pause.circle.fill")
+ }
+ }
+}
diff --git a/PlexBar/TV/Services/TVPlexClient.swift b/PlexBar/TV/Services/TVPlexClient.swift
new file mode 100644
index 0000000..270b169
--- /dev/null
+++ b/PlexBar/TV/Services/TVPlexClient.swift
@@ -0,0 +1,983 @@
+import PlexModels
+import Foundation
+
+actor TVPlexClient {
+ private let session: URLSession
+ private var providerEndpointsByServerIdentifier: [String: PlexLibraryProviderEndpoints] = [:]
+
+ init(session: URLSession = .shared) {
+ self.session = session
+ }
+
+ func validate(
+ _ connection: TVPlexConnection,
+ timeoutInterval: TimeInterval? = nil
+ ) async throws -> TVPlexServerIdentity {
+ let data = try await data(
+ path: "/identity",
+ timeoutInterval: timeoutInterval,
+ connection: connection
+ )
+ do {
+ return try JSONDecoder().decode(TVPlexIdentityEnvelope.self, from: data).mediaContainer
+ } catch {
+ throw TVPlexError.decodingFailed
+ }
+ }
+
+ func resolve(
+ _ server: PlexServerResource,
+ clientIdentifier: String
+ ) async throws -> TVPlexResolvedServer {
+ let connections = server.connections.sorted { lhs, rhs in
+ if lhs.priorityTier != rhs.priorityTier {
+ return lhs.priorityTier < rhs.priorityTier
+ }
+
+ let lhsIsHTTPS = lhs.uri.scheme?.localizedCaseInsensitiveCompare("https") == .orderedSame
+ let rhsIsHTTPS = rhs.uri.scheme?.localizedCaseInsensitiveCompare("https") == .orderedSame
+ if lhsIsHTTPS != rhsIsHTTPS {
+ return lhsIsHTTPS
+ }
+
+ return lhs.uri.absoluteString.localizedCaseInsensitiveCompare(
+ rhs.uri.absoluteString
+ ) == .orderedAscending
+ }
+
+ var failureCodes: [URLError.Code] = []
+ for advertisedConnection in connections {
+ let candidate = TVPlexConnection(
+ serverURL: advertisedConnection.uri,
+ token: server.accessToken,
+ clientIdentifier: clientIdentifier,
+ serverIdentifier: server.id,
+ kind: advertisedConnection.kind
+ )
+ do {
+ let identity = try await validate(candidate, timeoutInterval: 2.5)
+ guard let actualIdentifier = identity.machineIdentifier?.nilIfBlank else {
+ throw TVPlexError.invalidResponse
+ }
+ if actualIdentifier != server.id {
+ throw TVPlexError.serverIdentityMismatch(
+ expected: server.id,
+ actual: actualIdentifier
+ )
+ }
+ return TVPlexResolvedServer(connection: candidate, identity: identity)
+ } catch is CancellationError {
+ throw CancellationError()
+ } catch let error as URLError {
+ guard error.isConnectivityFailure else { throw error }
+ failureCodes.append(error.code)
+ }
+ }
+
+ throw PlexServerConnectionFailure(serverName: server.name, failureCodes: failureCodes)
+ }
+
+ func fetchHome(connection: TVPlexConnection) async throws -> [PlexHub] {
+ let endpoints = try await libraryProviderEndpoints(connection: connection)
+ guard let promotedPath = endpoints.promotedPath else {
+ throw PlexAPIError.missingLibraryPromotedFeature
+ }
+ guard let continueWatchingPath = endpoints.continueWatchingPath else {
+ throw PlexAPIError.missingLibraryContinueWatchingFeature
+ }
+ async let promoted = fetchHomeHubs(path: promotedPath, connection: connection)
+ async let continuation = fetchHomeHubs(path: continueWatchingPath, connection: connection)
+ return TVHomeContent.hubs(try await PlexHub.homeHubs(promoted: promoted, continueWatching: continuation))
+ }
+
+ private func fetchHomeHubs(path: String, connection: TVPlexConnection) async throws -> [PlexHub] {
+ let data = try await data(
+ path: path,
+ queryItems: [
+ URLQueryItem(name: "count", value: "20"),
+ URLQueryItem(name: "includeGuids", value: "1"),
+ URLQueryItem(name: "includeMeta", value: "1"),
+ URLQueryItem(name: "includeExternalMedia", value: "1")
+ ],
+ connection: connection
+ )
+ do {
+ return try JSONDecoder().decode(PlexHubEnvelope.self, from: data).mediaContainer.hubs
+ } catch {
+ throw TVPlexError.decodingFailed
+ }
+ }
+
+ func fetchLibraries(connection: TVPlexConnection) async throws -> [TVPlexLibrary] {
+ let data = try await data(path: "/library/sections/all", connection: connection)
+ let libraries: [TVPlexLibrary]
+ do {
+ libraries = try JSONDecoder().decode(TVPlexLibrariesEnvelope.self, from: data)
+ .mediaContainer.directories
+ .filter { ["movie", "show", "artist"].contains($0.type.lowercased()) }
+ } catch {
+ throw TVPlexError.decodingFailed
+ }
+ // Match macOS library summaries: the current library item supplies the
+ // artwork when the server has no section composite image.
+ return try await withThrowingTaskGroup(of: (Int, TVPlexLibrary).self) { group in
+ for (index, library) in libraries.enumerated() {
+ group.addTask {
+ let response = try await self.data(
+ path: "/library/sections/\(library.id)/all",
+ queryItems: [URLQueryItem(name: "sort", value: "addedAt:desc")],
+ headers: ["X-Plex-Container-Start": "0", "X-Plex-Container-Size": "1"],
+ connection: connection
+ )
+ let recent = try await self.decodeMediaPage(response).items.first
+ var library = library
+ library.recentArtworkPath = recent?.art?.nilIfBlank ?? recent?.thumb?.nilIfBlank
+ return (index, library)
+ }
+ }
+ var result: [(Int, TVPlexLibrary)] = []
+ for try await library in group { result.append(library) }
+ return result.sorted { $0.0 < $1.0 }.map(\.1)
+ }
+ }
+
+ func fetchLibrary(
+ _ library: TVPlexLibrary,
+ start: Int = 0,
+ size: Int = 60,
+ options: PlexLibraryBrowseOptions = .default,
+ connection: TVPlexConnection
+ ) async throws -> PlexMediaPage {
+ let data = try await data(
+ path: "/library/sections/\(library.id)/all",
+ queryItems: options.queryItems + [
+ URLQueryItem(name: "includeGuids", value: "1"),
+ URLQueryItem(name: "includeMeta", value: "1")
+ ],
+ headers: [
+ "X-Plex-Container-Start": String(start),
+ "X-Plex-Container-Size": String(size)
+ ],
+ connection: connection
+ )
+ return try decodeMediaPage(data)
+ }
+
+ func fetchHubPage(path: String, start: Int, size: Int = 60, connection: TVPlexConnection) async throws -> PlexMediaPage {
+ let response = try await data(
+ path: path,
+ headers: ["X-Plex-Container-Start": String(start), "X-Plex-Container-Size": String(size)],
+ connection: connection
+ )
+ return try decodeMediaPage(response, defaultOffset: start)
+ }
+
+ func fetchLibraryBrowseDefinition(_ library: TVPlexLibrary, connection: TVPlexConnection) async throws -> PlexLibraryBrowseDefinition {
+ let path = "/library/sections/\(library.id)"
+ async let filters = data(path: path + "/filters", connection: connection)
+ async let sorts = data(path: path + "/sorts", connection: connection)
+ let responses = try await (filters, sorts)
+ do {
+ return try PlexLibraryBrowseDefinition(
+ contentPath: path + "/all",
+ filters: JSONDecoder().decode(PlexLibraryFilterEnvelope.self, from: responses.0).mediaContainer.filters,
+ sorts: JSONDecoder().decode(PlexLibrarySortEnvelope.self, from: responses.1).mediaContainer.sorts
+ )
+ } catch {
+ throw TVPlexError.decodingFailed
+ }
+ }
+
+ func fetchLibraryFilterValues(_ filter: PlexLibraryFilterDefinition, connection: TVPlexConnection) async throws -> [PlexLibraryFilterValue] {
+ guard let path = filter.valuesPath else { throw PlexAPIError.invalidResponse }
+ let response = try await data(path: path, connection: connection)
+ do {
+ return try JSONDecoder().decode(PlexLibraryFilterValuesEnvelope.self, from: response)
+ .mediaContainer.values(for: filter)
+ } catch let error as PlexAPIError {
+ throw error
+ } catch {
+ throw TVPlexError.decodingFailed
+ }
+ }
+
+ func fetchMetadata(
+ ratingKey: String,
+ connection: TVPlexConnection
+ ) async throws -> PlexMediaItem {
+ try await fetchMetadata(
+ path: "/library/metadata/\(ratingKey)",
+ connection: connection
+ )
+ }
+
+ func fetchMediaExtras(ratingKey: String, connection: TVPlexConnection) async throws -> [PlexMediaItem] {
+ let response = try await data(path: "/library/metadata/\(ratingKey)/extras", connection: connection)
+ return try decodeMediaPage(response).items
+ }
+
+ func fetchRelatedHubs(ratingKey: String, connection: TVPlexConnection) async throws -> [PlexHub] {
+ let response = try await data(
+ path: "/hubs/metadata/\(ratingKey)/related",
+ queryItems: [URLQueryItem(name: "count", value: "12")],
+ connection: connection
+ )
+ do {
+ return try JSONDecoder().decode(PlexHubEnvelope.self, from: response)
+ .mediaContainer.hubs.filter { !$0.metadata.isEmpty }
+ } catch {
+ throw TVPlexError.decodingFailed
+ }
+ }
+
+ func fetchMetadata(
+ path: String,
+ connection: TVPlexConnection
+ ) async throws -> PlexMediaItem {
+ let data = try await data(
+ path: path,
+ queryItems: [
+ URLQueryItem(name: "includeGuids", value: "1"),
+ URLQueryItem(name: "includeConcerts", value: "1"),
+ URLQueryItem(name: "includeExtras", value: "1"),
+ URLQueryItem(name: "includeOptionalElements", value: "Chapter,Image,Marker,Rating")
+ ],
+ connection: connection
+ )
+ guard let item = try decodeMediaPage(data).items.first else {
+ throw TVPlexError.invalidResponse
+ }
+ return item
+ }
+
+ func fetchEpisodeSeriesCast(
+ for item: PlexMediaItem,
+ connection: TVPlexConnection
+ ) async throws -> [PlexTag] {
+ guard let seriesRatingKey = item.episodeSeriesCastRatingKey else { return [] }
+ let series = try await fetchMetadata(ratingKey: seriesRatingKey, connection: connection)
+ guard series.ratingKey == seriesRatingKey,
+ series.type?.caseInsensitiveCompare("show") == .orderedSame else {
+ throw TVPlexError.invalidResponse
+ }
+ return series.roles
+ }
+
+ func fetchPerson(
+ identifier: String,
+ connection: TVPlexConnection
+ ) async throws -> PlexTag {
+ let data = try await data(
+ path: "/library/people",
+ appendingPathComponents: [identifier],
+ connection: connection
+ )
+ do {
+ guard let person = try JSONDecoder()
+ .decode(PlexPeopleEnvelope.self, from: data)
+ .mediaContainer
+ .people
+ .first else {
+ throw TVPlexError.invalidResponse
+ }
+ return person
+ } catch let error as TVPlexError {
+ throw error
+ } catch {
+ throw TVPlexError.decodingFailed
+ }
+ }
+
+ func fetchPersonMedia(
+ identifier: String,
+ connection: TVPlexConnection
+ ) async throws -> [PlexMediaItem] {
+ let data = try await data(
+ path: "/library/people",
+ appendingPathComponents: [identifier, "media"],
+ connection: connection
+ )
+ return try decodeMediaPage(data).items
+ }
+
+ func fetchChildren(
+ of item: PlexMediaItem,
+ connection: TVPlexConnection
+ ) async throws -> [PlexMediaItem] {
+ try await fetchChildren(ratingKey: item.ratingKey, connection: connection)
+ }
+
+ func fetchNextEpisode(
+ after item: PlexMediaItem,
+ connection: TVPlexConnection
+ ) async throws -> PlexMediaItem? {
+ guard item.type?.lowercased() == "episode",
+ let seasonRatingKey = item.parentRatingKey else {
+ return nil
+ }
+
+ let seasonEpisodes = try await fetchChildren(
+ ratingKey: seasonRatingKey,
+ connection: connection
+ ).filter { $0.type?.lowercased() == "episode" }
+ if let next = PlexEpisodeContinuity.nextEpisode(after: item, in: seasonEpisodes) {
+ return next
+ }
+
+ guard let showRatingKey = item.grandparentRatingKey else {
+ return nil
+ }
+ let seasons = try await fetchChildren(
+ ratingKey: showRatingKey,
+ connection: connection
+ ).filter { $0.type?.lowercased() == "season" }
+ guard let nextSeason = PlexEpisodeContinuity.nextSeason(
+ afterRatingKey: seasonRatingKey,
+ index: item.parentIndex,
+ in: seasons
+ ) else {
+ return nil
+ }
+
+ let nextSeasonEpisodes = try await fetchChildren(
+ ratingKey: nextSeason.ratingKey,
+ connection: connection
+ )
+ .filter { $0.type?.lowercased() == "episode" }
+ return PlexEpisodeContinuity.ordered(nextSeasonEpisodes).first
+ }
+
+ func createContinuousPlayQueue(
+ for item: PlexMediaItem,
+ connection: TVPlexConnection
+ ) async throws -> PlexPlaybackQueue {
+ let endpoints = try await libraryProviderEndpoints(connection: connection)
+ guard let playQueuePath = endpoints.playQueuePath else {
+ throw PlexAPIError.missingLibraryPlayQueueFeature
+ }
+ let queueRequest = try PlexContinuousPlayQueueRequest(
+ item: item,
+ serverIdentifier: connection.serverIdentifier,
+ providerIdentifier: endpoints.providerIdentifier
+ )
+ let data = try await data(
+ path: playQueuePath,
+ queryItems: queueRequest.queryItems,
+ method: "POST",
+ connection: connection
+ )
+ let page = try decodePlayQueuePage(data)
+ return try PlexPlaybackQueue(
+ page: page,
+ selectedRatingKey: item.ratingKey
+ )
+ }
+
+ func createCinemaPlayQueue(
+ for item: PlexMediaItem,
+ extrasPrefixCount: Int,
+ connection: TVPlexConnection
+ ) async throws -> PlexPlaybackQueue {
+ let endpoints = try await libraryProviderEndpoints(connection: connection)
+ guard let playQueuePath = endpoints.playQueuePath else {
+ throw PlexAPIError.missingLibraryPlayQueueFeature
+ }
+ let queueRequest = try PlexCinemaPlayQueueRequest(
+ item: item,
+ extrasPrefixCount: extrasPrefixCount,
+ serverIdentifier: connection.serverIdentifier,
+ providerIdentifier: endpoints.providerIdentifier
+ )
+ let data = try await data(
+ path: playQueuePath,
+ queryItems: queueRequest.queryItems,
+ method: "POST",
+ connection: connection
+ )
+ let page = try decodePlayQueuePage(data)
+ guard page.selectedItemID?.nilIfBlank != nil else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+ return try PlexPlaybackQueue(
+ page: page,
+ selectedRatingKey: item.ratingKey,
+ purpose: .cinemaPreplay(primaryRatingKey: item.ratingKey)
+ )
+ }
+
+ func fetchPlayQueuePage(
+ queueID: Int,
+ centeredOn playQueueItemID: String,
+ window: Int = 50,
+ connection: TVPlexConnection
+ ) async throws -> PlexPlayQueuePage {
+ guard queueID > 0,
+ let playQueueItemID = playQueueItemID.nilIfBlank else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+ let endpoints = try await libraryProviderEndpoints(connection: connection)
+ guard let playQueuePath = endpoints.playQueuePath else {
+ throw PlexAPIError.missingLibraryPlayQueueFeature
+ }
+ let data = try await data(
+ path: playQueuePath,
+ appendingPathComponents: [String(queueID)],
+ queryItems: [
+ URLQueryItem(name: "center", value: playQueueItemID),
+ URLQueryItem(name: "window", value: String(max(window, 1))),
+ URLQueryItem(name: "includeBefore", value: "1"),
+ URLQueryItem(name: "includeAfter", value: "1"),
+ ],
+ connection: connection
+ )
+ return try decodePlayQueuePage(data)
+ }
+
+ func setPlayQueueShuffled(
+ _ shuffled: Bool,
+ queueID: Int,
+ connection: TVPlexConnection
+ ) async throws -> PlexPlayQueuePage {
+ let mutation = try PlexPlayQueueMutationRequest(
+ queueID: queueID,
+ mutation: .shuffled(shuffled)
+ )
+ let endpoints = try await libraryProviderEndpoints(connection: connection)
+ guard let playQueuePath = endpoints.playQueuePath else {
+ throw PlexAPIError.missingLibraryPlayQueueFeature
+ }
+ let data = try await data(
+ path: playQueuePath,
+ appendingPathComponents: mutation.endpointPathComponents,
+ method: "PUT",
+ connection: connection
+ )
+ return try decodePlayQueuePage(data)
+ }
+
+ func removePlayQueueItem(
+ queueID: Int,
+ playQueueItemID: String,
+ connection: TVPlexConnection
+ ) async throws -> PlexPlayQueuePage {
+ let mutation = try PlexPlayQueueItemMutationRequest(
+ queueID: queueID,
+ mutation: .remove(playQueueItemID: playQueueItemID)
+ )
+ let endpoints = try await libraryProviderEndpoints(connection: connection)
+ guard let playQueuePath = endpoints.playQueuePath else {
+ throw PlexAPIError.missingLibraryPlayQueueFeature
+ }
+ let data = try await data(
+ path: playQueuePath,
+ appendingPathComponents: mutation.endpointPathComponents,
+ method: mutation.method,
+ connection: connection
+ )
+ return try decodePlayQueuePage(data)
+ }
+
+ func movePlayQueueItem(
+ queueID: Int,
+ move: PlexPlayQueueItemMove,
+ connection: TVPlexConnection
+ ) async throws -> PlexPlayQueuePage {
+ let mutation = try PlexPlayQueueItemMutationRequest(
+ queueID: queueID,
+ mutation: .move(move)
+ )
+ let endpoints = try await libraryProviderEndpoints(connection: connection)
+ guard let playQueuePath = endpoints.playQueuePath else {
+ throw PlexAPIError.missingLibraryPlayQueueFeature
+ }
+ let data = try await data(
+ path: playQueuePath,
+ appendingPathComponents: mutation.endpointPathComponents,
+ queryItems: mutation.queryItems,
+ method: mutation.method,
+ connection: connection
+ )
+ return try decodePlayQueuePage(data)
+ }
+
+ func resetPlayQueue(
+ queueID: Int,
+ connection: TVPlexConnection
+ ) async throws -> PlexPlayQueuePage {
+ let mutation = try PlexPlayQueueMutationRequest(
+ queueID: queueID,
+ mutation: .reset
+ )
+ let endpoints = try await libraryProviderEndpoints(connection: connection)
+ guard let playQueuePath = endpoints.playQueuePath else {
+ throw PlexAPIError.missingLibraryPlayQueueFeature
+ }
+ let data = try await data(
+ path: playQueuePath,
+ appendingPathComponents: mutation.endpointPathComponents,
+ method: "PUT",
+ connection: connection
+ )
+ return try decodePlayQueuePage(data)
+ }
+
+ func search(
+ query: String,
+ connection: TVPlexConnection
+ ) async throws -> [PlexHub] {
+ let endpoints = try await libraryProviderEndpoints(connection: connection)
+ guard let searchPath = endpoints.searchPath else { throw PlexAPIError.missingLibrarySearchFeature }
+ let data = try await data(
+ path: searchPath,
+ queryItems: [
+ URLQueryItem(name: "query", value: query),
+ URLQueryItem(name: "limit", value: "60"),
+ URLQueryItem(name: "includeCollections", value: "1"),
+ URLQueryItem(name: "includeExternalMedia", value: "0")
+ ],
+ connection: connection
+ )
+ do {
+ return try JSONDecoder().decode(PlexHubEnvelope.self, from: data)
+ .mediaContainer.hubs
+ .filter { !$0.metadata.isEmpty }
+ } catch {
+ throw TVPlexError.decodingFailed
+ }
+ }
+
+ func playbackPlan(
+ for item: PlexMediaItem,
+ startTime: TimeInterval,
+ sessionIdentifier: String,
+ videoQuality: PlexVideoQuality,
+ musicQuality: PlexMusicQuality,
+ audioBoost: PlexAudioBoost,
+ streamingPolicy: PlexPlaybackStreamingPolicy,
+ subtitleBurnMode: PlexSubtitleBurnMode,
+ subtitleSize: PlexSubtitleSize,
+ automaticallySyncSubtitles: Bool,
+ automaticallyAdjustVideoQuality: Bool,
+ playSmallerVideosAtOriginalQuality: Bool,
+ forceVideoTranscode: Bool,
+ source requestedSource: PlexPlaybackSource? = nil,
+ forceServerMediaSelection: Bool = false,
+ connection: TVPlexConnection
+ ) async throws -> PlexPlaybackPlan {
+ guard item.isPlayable,
+ let source = requestedSource ?? item.defaultPlaybackSource,
+ item.playbackSource(mediaIndex: source.mediaIndex) == source else {
+ throw TVPlexError.noPlayableMedia
+ }
+
+ let capabilities = NativePlaybackCapabilityProbe.current()
+ let mediaKind = PlexPlaybackMediaKind(media: item.media[source.mediaIndex])
+ let requestParameters = PlexPlaybackRequestParameters(
+ item: item,
+ source: source,
+ videoQuality: videoQuality,
+ musicQuality: musicQuality,
+ audioBoost: audioBoost,
+ streamingPolicy: streamingPolicy,
+ subtitleBurnMode: subtitleBurnMode,
+ subtitleSize: subtitleSize,
+ automaticallySyncSubtitles: automaticallySyncSubtitles,
+ automaticallyAdjustVideoQuality: automaticallyAdjustVideoQuality,
+ playSmallerVideosAtOriginalQuality: playSmallerVideosAtOriginalQuality,
+ forceVideoTranscode: forceVideoTranscode,
+ sessionIdentifier: sessionIdentifier,
+ startTime: startTime,
+ forceServerMediaSelection: forceServerMediaSelection
+ )
+
+ if streamingPolicy.forceDirectPlay,
+ requestParameters.permitsDirectPlay,
+ let path = capabilities.directPlayPath(for: item, source: source) {
+ let url = try playbackURL(
+ path: path,
+ queryItems: [],
+ sessionIdentifier: sessionIdentifier,
+ connection: connection
+ )
+ return PlexPlaybackPlan(
+ url: url,
+ method: .directPlay,
+ mediaKind: mediaKind,
+ sessionIdentifier: sessionIdentifier,
+ ratingKey: item.ratingKey,
+ duration: item.duration.map { TimeInterval($0) / 1_000 },
+ startTime: max(startTime.isFinite ? startTime : 0, 0),
+ source: source,
+ usesServerMediaSelection: false
+ )
+ }
+
+ let queryItems = requestParameters.queryItems
+ let decisionData = try await data(
+ path: mediaKind.decisionPath,
+ queryItems: queryItems,
+ headers: [
+ "X-Plex-Session-Identifier": sessionIdentifier,
+ "X-Plex-Client-Profile-Name": "generic",
+ "X-Plex-Client-Profile-Extra": capabilities.clientProfileExtra(for: mediaKind)
+ ],
+ connection: connection
+ )
+ let decision: PlexPlaybackDecisionContainer
+ do {
+ decision = try JSONDecoder()
+ .decode(PlexPlaybackDecisionEnvelope.self, from: decisionData)
+ .mediaContainer
+ } catch {
+ throw TVPlexError.decodingFailed
+ }
+
+ let selection: PlexPlaybackSelection
+ switch PlexPlaybackDecisionResolver.resolve(decision, mediaKind: mediaKind) {
+ case .selected(let value):
+ selection = value
+ case .rejected(let reason):
+ throw TVPlexError.playbackRejected(reason)
+ case .noPlayableMedia:
+ throw TVPlexError.noPlayableMedia
+ }
+
+ let playbackQueryItems: [URLQueryItem] = switch selection.method {
+ case .directPlay:
+ []
+ case .directStream, .transcode:
+ queryItems + [
+ URLQueryItem(name: "X-Plex-Client-Profile-Name", value: "generic"),
+ URLQueryItem(
+ name: "X-Plex-Client-Profile-Extra",
+ value: capabilities.clientProfileExtra(for: mediaKind)
+ )
+ ]
+ }
+ let url = try playbackURL(
+ path: selection.path,
+ queryItems: playbackQueryItems,
+ sessionIdentifier: sessionIdentifier,
+ connection: connection
+ )
+ return PlexPlaybackPlan(
+ url: url,
+ method: selection.method,
+ mediaKind: mediaKind,
+ sessionIdentifier: sessionIdentifier,
+ ratingKey: item.ratingKey,
+ duration: item.duration.map { TimeInterval($0) / 1_000 },
+ startTime: max(startTime.isFinite ? startTime : 0, 0),
+ source: source,
+ usesServerMediaSelection: forceServerMediaSelection,
+ supportsAudioBoost: selection.supportsAudioBoost
+ && requestParameters.hasMultichannelAudioSource,
+ supportsSubtitleAutoSync: requestParameters.supportsSubtitleAutoSync
+ )
+ }
+
+ private func playbackURL(
+ path: String,
+ queryItems: [URLQueryItem],
+ sessionIdentifier: String,
+ connection: TVPlexConnection
+ ) throws -> URL {
+ var authenticatedQueryItems = queryItems
+ authenticatedQueryItems += PlexClientContext(
+ clientIdentifier: connection.clientIdentifier
+ ).headers
+ .sorted { $0.key < $1.key }
+ .map { URLQueryItem(name: $0.key, value: $0.value) }
+ authenticatedQueryItems += [
+ URLQueryItem(name: "X-Plex-Session-Identifier", value: sessionIdentifier),
+ URLQueryItem(name: "X-Plex-Token", value: connection.token),
+ ]
+ return try endpoint(
+ path: path,
+ queryItems: authenticatedQueryItems,
+ connection: connection
+ )
+ }
+
+ func artworkURL(
+ path: String?,
+ width: Int,
+ height: Int,
+ connection: TVPlexConnection,
+ usesOriginalImage: Bool = false
+ ) -> URL? {
+ guard let path, !path.isEmpty else { return nil }
+ // Clear logos must retain their original alpha channel. The photo
+ // transcode endpoint used by posters explicitly produces JPEG.
+ let imageURL = usesOriginalImage
+ ? PlexURLBuilder.mediaURL(serverURL: connection.serverURL, path: path)
+ : PlexURLBuilder.transcodedPhotoURL(
+ serverURL: connection.serverURL,
+ path: path,
+ width: width,
+ height: height
+ )
+ guard let imageURL,
+ var components = URLComponents(url: imageURL, resolvingAgainstBaseURL: false) else {
+ return nil
+ }
+ components.queryItems = (components.queryItems ?? []) + [
+ URLQueryItem(name: "X-Plex-Token", value: connection.token)
+ ]
+ return components.url
+ }
+
+ func fetchArtworkData(
+ path: String,
+ width: Int,
+ height: Int,
+ connection: TVPlexConnection,
+ timeoutInterval: TimeInterval? = nil
+ ) async throws -> Data {
+ guard let url = artworkURL(
+ path: path,
+ width: width,
+ height: height,
+ connection: connection
+ ) else {
+ throw TVPlexError.invalidServerURL
+ }
+ var request = PlexRequestBuilder(
+ clientContext: PlexClientContext(clientIdentifier: connection.clientIdentifier)
+ ).request(url: url, accept: "image/*", token: connection.token)
+ if let timeoutInterval { request.timeoutInterval = timeoutInterval }
+ let (data, response) = try await session.data(for: request)
+ guard let response = response as? HTTPURLResponse else {
+ throw TVPlexError.invalidResponse
+ }
+ guard (200..<300).contains(response.statusCode) else {
+ throw TVPlexError.badStatus(response.statusCode)
+ }
+ guard !data.isEmpty else {
+ throw TVPlexError.invalidResponse
+ }
+ return data
+ }
+
+ func selectMediaStreams(
+ partID: Int,
+ audioStreamID: Int? = nil,
+ subtitleStreamID: Int? = nil,
+ connection: TVPlexConnection
+ ) async throws {
+ let parameters = PlexMediaSelectionRequestParameters(
+ partID: partID,
+ audioStreamID: audioStreamID,
+ subtitleStreamID: subtitleStreamID,
+ allParts: true
+ )
+ guard parameters.hasSelection else {
+ throw TVPlexError.invalidResponse
+ }
+ _ = try await data(
+ path: parameters.path,
+ queryItems: parameters.queryItems,
+ method: "PUT",
+ connection: connection
+ )
+ }
+
+ func setSubtitleOffset(
+ streamID: Int,
+ milliseconds: Int,
+ connection: TVPlexConnection
+ ) async throws {
+ let parameters = PlexSubtitleOffsetRequestParameters(
+ streamID: streamID,
+ milliseconds: milliseconds
+ )
+ _ = try await data(
+ path: parameters.path,
+ queryItems: parameters.queryItems,
+ method: "PUT",
+ connection: connection
+ )
+ }
+
+ func reportTimeline(
+ _ update: PlexTimelineUpdate,
+ connection: TVPlexConnection
+ ) async -> PlexTimelineResponse? {
+ guard let endpoints = try? await libraryProviderEndpoints(connection: connection),
+ let timelinePath = endpoints.timelinePath,
+ let data = try? await data(
+ path: timelinePath,
+ queryItems: PlexTimelineRequestParameters(update: update).queryItems,
+ headers: ["X-Plex-Session-Identifier": update.sessionIdentifier],
+ method: "POST",
+ connection: connection
+ ) else {
+ return nil
+ }
+ return try? JSONDecoder()
+ .decode(PlexTimelineResponseEnvelope.self, from: data)
+ .mediaContainer
+ .response
+ }
+
+ func markWatched(_ item: PlexMediaItem, connection: TVPlexConnection) async {
+ guard let endpoints = try? await libraryProviderEndpoints(connection: connection),
+ let parameters = try? PlexWatchedStateRequestParameters(
+ watched: true,
+ ratingKey: item.ratingKey,
+ endpoints: endpoints
+ ) else {
+ return
+ }
+ _ = try? await data(
+ path: parameters.endpointPath,
+ queryItems: parameters.queryItems,
+ method: "PUT",
+ connection: connection
+ )
+ }
+
+ private func decodeMediaPage(_ data: Data, defaultOffset: Int = 0) throws -> PlexMediaPage {
+ do {
+ let container = try JSONDecoder().decode(PlexMediaEnvelope.self, from: data).mediaContainer
+ return PlexMediaPage(
+ items: container.metadata,
+ offset: container.offset ?? defaultOffset,
+ totalSize: container.totalSize
+ )
+ } catch {
+ throw TVPlexError.decodingFailed
+ }
+ }
+
+ private func decodePlayQueuePage(_ data: Data) throws -> PlexPlayQueuePage {
+ do {
+ return try JSONDecoder()
+ .decode(PlexPlayQueueEnvelope.self, from: data)
+ .mediaContainer
+ .page()
+ } catch let error as PlexAPIError {
+ throw error
+ } catch {
+ throw TVPlexError.decodingFailed
+ }
+ }
+
+ private func libraryProviderEndpoints(
+ connection: TVPlexConnection
+ ) async throws -> PlexLibraryProviderEndpoints {
+ if let cached = providerEndpointsByServerIdentifier[connection.serverIdentifier] {
+ return cached
+ }
+ let data = try await data(path: "/media/providers", connection: connection)
+ do {
+ let endpoints = try JSONDecoder()
+ .decode(PlexMediaProvidersEnvelope.self, from: data)
+ .mediaContainer
+ .libraryProviderEndpoints()
+ providerEndpointsByServerIdentifier[connection.serverIdentifier] = endpoints
+ return endpoints
+ } catch let error as PlexAPIError {
+ throw error
+ } catch {
+ throw TVPlexError.decodingFailed
+ }
+ }
+
+ func fetchChildren(
+ ratingKey: String,
+ connection: TVPlexConnection
+ ) async throws -> [PlexMediaItem] {
+ let data = try await data(
+ path: "/library/metadata/\(ratingKey)/children",
+ headers: ["X-Plex-Container-Size": "500"],
+ connection: connection
+ )
+ return try decodeMediaPage(data).items
+ }
+
+ private func data(
+ path: String,
+ appendingPathComponents: [String] = [],
+ queryItems: [URLQueryItem] = [],
+ headers: [String: String] = [:],
+ method: String = "GET",
+ timeoutInterval: TimeInterval? = nil,
+ connection: TVPlexConnection
+ ) async throws -> Data {
+ let url = try endpoint(
+ path: path,
+ appendingPathComponents: appendingPathComponents,
+ queryItems: queryItems,
+ connection: connection
+ )
+ var request = PlexRequestBuilder(
+ clientContext: PlexClientContext(clientIdentifier: connection.clientIdentifier)
+ ).request(url: url, accept: "application/json", token: connection.token)
+ request.httpMethod = method
+ if let timeoutInterval {
+ request.timeoutInterval = timeoutInterval
+ }
+ headers.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
+
+ let (data, response) = try await session.data(for: request)
+ guard let response = response as? HTTPURLResponse else {
+ throw TVPlexError.invalidResponse
+ }
+ guard (200..<300).contains(response.statusCode) else {
+ throw TVPlexError.badStatus(response.statusCode)
+ }
+ return data
+ }
+
+ private func endpoint(
+ path: String,
+ appendingPathComponents: [String] = [],
+ queryItems: [URLQueryItem] = [],
+ connection: TVPlexConnection
+ ) throws -> URL {
+ let endpoint = if appendingPathComponents.isEmpty {
+ PlexURLBuilder.endpointURL(
+ serverURL: connection.serverURL,
+ path: path
+ )
+ } else {
+ PlexURLBuilder.endpointURL(
+ serverURL: connection.serverURL,
+ path: path,
+ appendingPathComponents: appendingPathComponents
+ )
+ }
+ guard let endpoint,
+ var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else {
+ throw TVPlexError.invalidServerURL
+ }
+ components.queryItems = (components.queryItems ?? []) + queryItems
+ guard let url = components.url else {
+ throw TVPlexError.invalidServerURL
+ }
+ return url
+ }
+
+}
+
+private extension URLError {
+ var isConnectivityFailure: Bool {
+ switch code {
+ case .timedOut,
+ .cannotFindHost,
+ .cannotConnectToHost,
+ .networkConnectionLost,
+ .dnsLookupFailed,
+ .notConnectedToInternet,
+ .internationalRoamingOff,
+ .callIsActive,
+ .dataNotAllowed,
+ .secureConnectionFailed,
+ .cannotLoadFromNetwork:
+ true
+ default:
+ false
+ }
+ }
+}
diff --git a/PlexBar/TV/Services/TVTopShelfPublisher.swift b/PlexBar/TV/Services/TVTopShelfPublisher.swift
new file mode 100644
index 0000000..c9d8d4e
--- /dev/null
+++ b/PlexBar/TV/Services/TVTopShelfPublisher.swift
@@ -0,0 +1,93 @@
+import PlexModels
+import Foundation
+import ImageIO
+import os
+import TVServices
+
+@MainActor
+final class TVTopShelfPublisher {
+ private let cache: @Sendable () throws -> TVTopShelfCache
+ private let notify: @MainActor () -> Void
+ private var task: Task?
+ private let logger = Logger(subsystem: "com.crapshack.PlexBar.tv", category: "TopShelf")
+
+ init(
+ cache: @escaping @Sendable () throws -> TVTopShelfCache = { try .shared() },
+ notify: @escaping @MainActor () -> Void = { TVTopShelfContentProvider.topShelfContentDidChange() }
+ ) {
+ self.cache = cache
+ self.notify = notify
+ }
+
+ func clear() {
+ task?.cancel()
+ task = nil
+ do {
+ try cache().clear()
+ } catch {
+ logger.error("Unable to clear Top Shelf: \(error.localizedDescription, privacy: .public)")
+ }
+ notify()
+ }
+
+ func publish(hubs: [PlexHub], connection: TVPlexConnection, client: TVPlexClient) {
+ task?.cancel()
+ logger.debug("Preparing Top Shelf from Plex hubs: \(hubs.map(\.hubIdentifier).joined(separator: ","), privacy: .public)")
+ let selection = TVTopShelfSelection(hubs: hubs)
+ task = Task {
+ do {
+ let cache = try cache()
+ var sections: [TVTopShelfSnapshot.Section] = []
+ for section in selection.sections {
+ let entries = await PlexBoundedConcurrentMap.compactMap(section.items, maximumConcurrentTasks: 4) { item in
+ guard !Task.isCancelled else { return nil as (PlexMediaItem, Data)? }
+ guard let path = TVTopShelfSelection.artworkPath(for: item) else { return nil as (PlexMediaItem, Data)? }
+ let shape = TVTopShelfSelection.shape(for: item)
+ let size = TVTopShelfSectionedContent.imageSize(for: shape == .poster ? .poster : .square)
+ do {
+ let data = try await client.fetchArtworkData(
+ path: path, width: Int(size.width * 2), height: Int(size.height * 2),
+ connection: connection, timeoutInterval: 10
+ )
+ guard let source = CGImageSourceCreateWithData(data as CFData, nil),
+ CGImageSourceGetCount(source) > 0 else { throw TVPlexError.invalidResponse }
+ return (item, data)
+ } catch {
+ guard !Task.isCancelled else { return nil }
+ // A failed image is omitted; never hand TVServices a broken or authenticated URL.
+ Logger(subsystem: "com.crapshack.PlexBar.tv", category: "TopShelf")
+ .error("Top Shelf artwork failed for item \(item.ratingKey, privacy: .public): \(error.localizedDescription, privacy: .private)")
+ return nil
+ }
+ }
+ try Task.checkCancellation()
+ let items = try entries.map { item, data in
+ return TVTopShelfSnapshot.Item(
+ ratingKey: item.ratingKey, title: TVTopShelfSelection.title(for: item),
+ imageFilename: try cache.storeImage(data), shape: TVTopShelfSelection.shape(for: item),
+ playbackProgress: TVTopShelfSelection.progress(for: item), canPlay: item.tvCanStartPlayback
+ )
+ }
+ if !items.isEmpty {
+ sections.append(.init(identifier: section.identifier, title: section.title, items: items))
+ }
+ }
+ // No suspension between cancellation check and commit: a cleared session cannot republish.
+ try Task.checkCancellation()
+ let snapshot = TVTopShelfSnapshot(serverIdentifier: connection.serverIdentifier, sections: sections)
+ try cache.write(snapshot)
+ notify()
+ try cache.pruneImages(keeping: snapshot)
+ } catch is CancellationError {
+ return
+ } catch {
+ guard !Task.isCancelled else { return }
+ logger.error("Unable to publish Top Shelf: \(error.localizedDescription, privacy: .public)")
+ }
+ }
+ }
+
+ func waitForPublication() async {
+ await task?.value
+ }
+}
diff --git a/PlexBar/TV/Stores/TVAppStore.swift b/PlexBar/TV/Stores/TVAppStore.swift
new file mode 100644
index 0000000..ea18b39
--- /dev/null
+++ b/PlexBar/TV/Stores/TVAppStore.swift
@@ -0,0 +1,1819 @@
+import PlexModels
+import Foundation
+import Observation
+
+@MainActor
+@Observable
+final class TVAppStore {
+ enum Tab: Hashable {
+ case home
+ case libraries
+ case search
+ case settings
+ }
+
+ enum ConnectionState: Equatable {
+ case disconnected
+ case connecting
+ case connected(serverName: String)
+ }
+
+ private enum DefaultsKey {
+ static let selectedServerIdentifier = "tv.plex.selectedServerIdentifier"
+ static let skipIntroBehavior = "tv.playback.skipIntroBehavior"
+ static let skipAdsBehavior = "tv.playback.skipAdsBehavior"
+ static let skipCreditsBehavior = "tv.playback.skipCreditsBehavior"
+ static let autoplayNextEpisode = "tv.playback.autoplayNextEpisode"
+ static let legacyVideoQuality = "tv.playback.videoQuality"
+ static let localVideoQuality = "tv.playback.localVideoQuality"
+ static let remoteVideoQuality = "tv.playback.remoteVideoQuality"
+ static let remoteMusicQuality = "tv.playback.remoteMusicQuality"
+ static let audioBoost = "tv.playback.audioBoost"
+ static let videoScalingMode = "tv.playback.videoScalingMode"
+ static let cinemaPreplayPreference = "tv.playback.cinemaPreplayPreference"
+ static let rewindOnResumeSeconds = "tv.playback.rewindOnResumeSeconds"
+ static let autoplayCountdown = "tv.playback.autoplayCountdown"
+ static let passoutProtection = "tv.playback.passoutProtection"
+ static let qualitySuggestionsEnabled = "tv.playback.qualitySuggestionsEnabled"
+ static let automaticallyAdjustVideoQuality = "tv.playback.automaticallyAdjustVideoQuality"
+ static let playSmallerVideosAtOriginalQuality = "tv.playback.playSmallerVideosAtOriginalQuality"
+ static let allowsDirectPlay = "tv.playback.allowsDirectPlay"
+ static let allowsDirectStream = "tv.playback.allowsDirectStream"
+ static let forceDirectPlay = "tv.playback.forceDirectPlay"
+ static let subtitleBurnMode = "tv.playback.subtitleBurnMode"
+ static let subtitleSize = "tv.playback.subtitleSize"
+ static let automaticallySyncSubtitles = "tv.playback.automaticallySyncSubtitles"
+ }
+
+ private let client: TVPlexClient
+ private let authClient: PlexAuthClient
+ private let deviceIdentityStore: any PlexDeviceIdentityProviding
+ private let keychain: KeychainStore
+ private let defaults: UserDefaults
+ private let accountStorage: TVPlexAccountJWTStorage
+ private let accountJWTManager: PlexAccountJWTManager
+ private var libraryRequestIDs: [String: UUID] = [:]
+ private var libraryQueries: [String: LibraryQuery] = [:]
+ private var libraryNextOffsets: [String: Int] = [:]
+ private var libraryPaginationFailureIDs: Set = []
+
+ private struct LibraryQuery: Equatable {
+ let connection: TVPlexConnection
+ let options: PlexLibraryBrowseOptions
+ }
+ private var searchTask: Task?
+ private var signInTask: Task?
+ private var accountTokenRefreshTask: Task?
+ private var playbackPreparationTask: Task?
+ private var playbackPreparationID = UUID()
+ private var homeRefreshID = UUID()
+ private let topShelfPublisher: TVTopShelfPublisher
+ private var topShelfRouteTask: Task?
+ private var pendingTopShelfRoute: TVTopShelfRoute?
+ private var hasRestoredSession = false
+ private var sessionRevision = UUID()
+
+ var selectedTab: Tab = .home
+ var homePath: [TVNavigationRoute] = []
+ var connectionState: ConnectionState = .disconnected
+ var homeHubs: [PlexHub] = []
+ var libraries: [TVPlexLibrary] = []
+ var libraryItems: [String: [PlexMediaItem]] = [:]
+ var libraryTotalSizes: [String: Int] = [:]
+ var libraryErrors: [String: String] = [:]
+ var searchQuery = ""
+ var searchHubs: [PlexHub] = []
+ var searchErrorMessage: String?
+ var isLoadingHome = false
+ var isLoadingLibraries = false
+ var isSearching = false
+ var errorMessage: String?
+ var playbackRequest: TVPlexPlaybackRequest?
+ private(set) var playbackMetadataRevision = UUID()
+ private(set) var playbackSleepTimer = PlexPlaybackSleepTimer.off
+ private(set) var playbackPreparation: TVPlaybackPreparation?
+ var signInCode: String?
+ var availableServers: [PlexServerResource] = []
+ var isPairing = true
+ var skipIntroBehavior: PlexPlaybackMarkerBehavior {
+ didSet {
+ defaults.set(skipIntroBehavior.rawValue, forKey: DefaultsKey.skipIntroBehavior)
+ }
+ }
+ var skipAdsBehavior: PlexPlaybackMarkerBehavior {
+ didSet {
+ defaults.set(skipAdsBehavior.rawValue, forKey: DefaultsKey.skipAdsBehavior)
+ }
+ }
+ var skipCreditsBehavior: PlexPlaybackMarkerBehavior {
+ didSet {
+ defaults.set(skipCreditsBehavior.rawValue, forKey: DefaultsKey.skipCreditsBehavior)
+ }
+ }
+ var autoplayNextEpisode: Bool {
+ didSet {
+ defaults.set(autoplayNextEpisode, forKey: DefaultsKey.autoplayNextEpisode)
+ }
+ }
+ var localVideoQuality: PlexVideoQuality {
+ didSet {
+ defaults.set(localVideoQuality.rawValue, forKey: DefaultsKey.localVideoQuality)
+ }
+ }
+ var remoteVideoQuality: PlexVideoQuality {
+ didSet {
+ defaults.set(remoteVideoQuality.rawValue, forKey: DefaultsKey.remoteVideoQuality)
+ }
+ }
+ var remoteMusicQuality: PlexMusicQuality {
+ didSet {
+ defaults.set(remoteMusicQuality.rawValue, forKey: DefaultsKey.remoteMusicQuality)
+ }
+ }
+ var audioBoost: PlexAudioBoost {
+ didSet {
+ defaults.set(audioBoost.rawValue, forKey: DefaultsKey.audioBoost)
+ }
+ }
+ var videoScalingMode: PlexVideoScalingMode {
+ didSet {
+ defaults.set(videoScalingMode.rawValue, forKey: DefaultsKey.videoScalingMode)
+ }
+ }
+ var cinemaPreplayPreference: PlexCinemaPreplayPreference {
+ didSet {
+ defaults.set(
+ cinemaPreplayPreference.rawValue,
+ forKey: DefaultsKey.cinemaPreplayPreference
+ )
+ }
+ }
+ var rewindOnResume: PlexRewindOnResume {
+ didSet {
+ defaults.set(
+ rewindOnResume.seconds,
+ forKey: DefaultsKey.rewindOnResumeSeconds
+ )
+ }
+ }
+ var autoplayCountdown: PlexAutoplayCountdown {
+ didSet {
+ defaults.set(String(autoplayCountdown.rawValue), forKey: DefaultsKey.autoplayCountdown)
+ }
+ }
+ var passoutProtection: PlexPassoutProtection {
+ didSet {
+ defaults.set(
+ passoutProtection.rawValue,
+ forKey: DefaultsKey.passoutProtection
+ )
+ }
+ }
+ var qualitySuggestionsEnabled: Bool {
+ didSet {
+ defaults.set(
+ qualitySuggestionsEnabled,
+ forKey: DefaultsKey.qualitySuggestionsEnabled
+ )
+ }
+ }
+ var automaticallyAdjustVideoQuality: Bool {
+ didSet {
+ defaults.set(
+ automaticallyAdjustVideoQuality,
+ forKey: DefaultsKey.automaticallyAdjustVideoQuality
+ )
+ }
+ }
+ var playSmallerVideosAtOriginalQuality: Bool {
+ didSet {
+ defaults.set(
+ playSmallerVideosAtOriginalQuality,
+ forKey: DefaultsKey.playSmallerVideosAtOriginalQuality
+ )
+ }
+ }
+ var allowsDirectPlay: Bool {
+ didSet {
+ defaults.set(allowsDirectPlay, forKey: DefaultsKey.allowsDirectPlay)
+ }
+ }
+ var allowsDirectStream: Bool {
+ didSet {
+ defaults.set(allowsDirectStream, forKey: DefaultsKey.allowsDirectStream)
+ }
+ }
+ var forceDirectPlay: Bool {
+ didSet {
+ defaults.set(forceDirectPlay, forKey: DefaultsKey.forceDirectPlay)
+ }
+ }
+ var subtitleBurnMode: PlexSubtitleBurnMode {
+ didSet {
+ defaults.set(subtitleBurnMode.rawValue, forKey: DefaultsKey.subtitleBurnMode)
+ }
+ }
+ var subtitleSize: PlexSubtitleSize {
+ didSet {
+ defaults.set(subtitleSize.rawValue, forKey: DefaultsKey.subtitleSize)
+ }
+ }
+ var automaticallySyncSubtitles: Bool {
+ didSet {
+ defaults.set(
+ automaticallySyncSubtitles,
+ forKey: DefaultsKey.automaticallySyncSubtitles
+ )
+ }
+ }
+
+ private(set) var connection: TVPlexConnection? {
+ didSet {
+ guard oldValue != connection else { return }
+ topShelfRouteTask?.cancel()
+ cancelPlaybackPreparation()
+ homePath = []
+ if oldValue?.serverIdentifier != connection?.serverIdentifier {
+ homeHubs = []
+ topShelfPublisher.clear()
+ }
+ }
+ }
+
+ var isConnected: Bool {
+ connection != nil
+ }
+
+ var hasAuthorizedAccount: Bool {
+ accountStorage.storedAccountToken.nilIfBlank != nil
+ }
+
+ var serverName: String {
+ guard case .connected(let serverName) = connectionState else { return "Plex" }
+ return serverName
+ }
+
+ var playbackMarkerPreferences: PlexPlaybackMarkerPreferences {
+ PlexPlaybackMarkerPreferences(
+ intro: skipIntroBehavior,
+ ads: skipAdsBehavior,
+ credits: skipCreditsBehavior
+ )
+ }
+
+ var autoplayPreferences: PlexAutoplayPreferences {
+ PlexAutoplayPreferences(
+ isEnabled: autoplayNextEpisode,
+ countdown: autoplayCountdown,
+ passoutProtection: passoutProtection
+ )
+ }
+
+ var activeVideoQuality: PlexVideoQuality {
+ videoQualityPreferences.quality(for: connection?.kind)
+ }
+
+ var activeMusicQuality: PlexMusicQuality {
+ musicQualityPreferences.quality(for: connection?.kind)
+ }
+
+ var playbackStreamingPolicy: PlexPlaybackStreamingPolicy {
+ PlexPlaybackStreamingPolicy(
+ allowsDirectPlay: allowsDirectPlay,
+ allowsDirectStream: allowsDirectStream,
+ forceDirectPlay: forceDirectPlay
+ )
+ }
+
+ private var videoQualityPreferences: PlexVideoQualityPreferences {
+ PlexVideoQualityPreferences(
+ local: localVideoQuality,
+ remote: remoteVideoQuality
+ )
+ }
+
+ private var musicQualityPreferences: PlexMusicQualityPreferences {
+ PlexMusicQualityPreferences(remote: remoteMusicQuality)
+ }
+
+ init(
+ client: TVPlexClient = TVPlexClient(),
+ defaults: UserDefaults = .standard,
+ authClient: PlexAuthClient = PlexAuthClient(),
+ deviceIdentityStore: any PlexDeviceIdentityProviding = PlexKeychainDeviceIdentityStore(),
+ keychain: KeychainStore = KeychainStore(service: AppConstants.bundleIdentifier),
+ topShelfPublisher: TVTopShelfPublisher = TVTopShelfPublisher()
+ ) {
+ self.client = client
+ self.authClient = authClient
+ self.deviceIdentityStore = deviceIdentityStore
+ self.keychain = keychain
+ self.defaults = defaults
+ self.topShelfPublisher = topShelfPublisher
+ let accountStorage = TVPlexAccountJWTStorage(defaults: defaults, keychain: keychain)
+ self.accountStorage = accountStorage
+ accountJWTManager = PlexAccountJWTManager(
+ storage: accountStorage,
+ client: authClient,
+ deviceIdentityStore: deviceIdentityStore
+ )
+ skipIntroBehavior = defaults.string(forKey: DefaultsKey.skipIntroBehavior)
+ .flatMap(PlexPlaybackMarkerBehavior.init(rawValue:)) ?? .manually
+ skipAdsBehavior = defaults.string(forKey: DefaultsKey.skipAdsBehavior)
+ .flatMap(PlexPlaybackMarkerBehavior.init(rawValue:)) ?? .manually
+ skipCreditsBehavior = defaults.string(forKey: DefaultsKey.skipCreditsBehavior)
+ .flatMap(PlexPlaybackMarkerBehavior.init(rawValue:)) ?? .manually
+ autoplayNextEpisode = defaults.object(forKey: DefaultsKey.autoplayNextEpisode) as? Bool ?? true
+ let legacyVideoQuality = defaults.string(forKey: DefaultsKey.legacyVideoQuality)
+ .flatMap(PlexVideoQuality.init(rawValue:))
+ localVideoQuality = defaults.string(forKey: DefaultsKey.localVideoQuality)
+ .flatMap(PlexVideoQuality.init(rawValue:))
+ ?? legacyVideoQuality
+ ?? .original
+ remoteVideoQuality = defaults.string(forKey: DefaultsKey.remoteVideoQuality)
+ .flatMap(PlexVideoQuality.init(rawValue:))
+ ?? legacyVideoQuality
+ ?? .original
+ remoteMusicQuality = defaults.string(forKey: DefaultsKey.remoteMusicQuality)
+ .flatMap(PlexMusicQuality.init(rawValue:))
+ ?? .original
+ audioBoost = (defaults.object(forKey: DefaultsKey.audioBoost) as? Int)
+ .flatMap(PlexAudioBoost.init(rawValue:))
+ ?? .none
+ videoScalingMode = defaults.string(forKey: DefaultsKey.videoScalingMode)
+ .flatMap(PlexVideoScalingMode.init(rawValue:))
+ ?? .fit
+ cinemaPreplayPreference = (
+ defaults.object(forKey: DefaultsKey.cinemaPreplayPreference) as? Int
+ )
+ .flatMap(PlexCinemaPreplayPreference.init(rawValue:))
+ ?? .off
+ rewindOnResume = PlexRewindOnResume(
+ seconds: defaults.object(forKey: DefaultsKey.rewindOnResumeSeconds) as? Int ?? 0
+ )
+ autoplayCountdown = defaults.string(forKey: DefaultsKey.autoplayCountdown)
+ .flatMap(Int.init)
+ .flatMap(PlexAutoplayCountdown.init(rawValue:))
+ ?? .fifteenSeconds
+ passoutProtection = (defaults.object(forKey: DefaultsKey.passoutProtection) as? Int)
+ .flatMap(PlexPassoutProtection.init(rawValue:))
+ ?? .twoHours
+ qualitySuggestionsEnabled = defaults.object(
+ forKey: DefaultsKey.qualitySuggestionsEnabled
+ ) as? Bool ?? true
+ automaticallyAdjustVideoQuality = defaults.object(
+ forKey: DefaultsKey.automaticallyAdjustVideoQuality
+ ) as? Bool ?? false
+ playSmallerVideosAtOriginalQuality = defaults.object(
+ forKey: DefaultsKey.playSmallerVideosAtOriginalQuality
+ ) as? Bool ?? true
+ allowsDirectPlay = defaults.object(
+ forKey: DefaultsKey.allowsDirectPlay
+ ) as? Bool ?? true
+ allowsDirectStream = defaults.object(
+ forKey: DefaultsKey.allowsDirectStream
+ ) as? Bool ?? true
+ forceDirectPlay = defaults.object(
+ forKey: DefaultsKey.forceDirectPlay
+ ) as? Bool ?? false
+ subtitleBurnMode = defaults.string(forKey: DefaultsKey.subtitleBurnMode)
+ .flatMap(PlexSubtitleBurnMode.init(rawValue:))
+ ?? .automatic
+ subtitleSize = (defaults.object(forKey: DefaultsKey.subtitleSize) as? Int)
+ .flatMap(PlexSubtitleSize.init(rawValue:))
+ ?? .normal
+ automaticallySyncSubtitles = defaults.object(
+ forKey: DefaultsKey.automaticallySyncSubtitles
+ ) as? Bool ?? true
+ }
+
+ func restoreSession() async {
+ guard !hasRestoredSession else { return }
+ defer {
+ hasRestoredSession = true
+ processPendingTopShelfRoute()
+ }
+ guard !isConnected else { return }
+ isPairing = false
+ do {
+ try await accountStorage.loadAccountToken()
+ } catch {
+ errorMessage = error.localizedDescription
+ return
+ }
+ guard hasAuthorizedAccount else {
+ topShelfPublisher.clear()
+ return
+ }
+ await reconnectAuthorizedAccount()
+ }
+
+ @discardableResult
+ private func connect(to server: PlexServerResource) async -> Bool {
+ let revision = sessionRevision
+ connectionState = .connecting
+ errorMessage = nil
+
+ do {
+ let resolved = try await client.resolve(
+ server,
+ clientIdentifier: accountStorage.clientIdentifier
+ )
+ guard revision == sessionRevision else { return false }
+ connection = resolved.connection
+ connectionState = .connected(
+ serverName: resolved.identity.friendlyName ?? server.name
+ )
+ await refreshAll()
+ guard revision == sessionRevision, connection == resolved.connection else { return false }
+ processPendingTopShelfRoute()
+ return true
+ } catch {
+ guard revision == sessionRevision else { return false }
+ connection = nil
+ connectionState = .disconnected
+ errorMessage = error.localizedDescription
+ return false
+ }
+ }
+
+ func startPlexDeviceAuthorization() {
+ signInTask?.cancel()
+ isPairing = true
+ signInCode = nil
+ availableServers = []
+ errorMessage = nil
+ let clientContext = PlexClientContext(clientIdentifier: accountStorage.clientIdentifier)
+
+ signInTask = Task { [weak self] in
+ guard let self else { return }
+ do {
+ let identity = try await deviceIdentityStore.loadOrCreateIdentity()
+ let pin = try await authClient.createPin(
+ jwk: identity.publicJWK(includeUse: false),
+ strong: false,
+ clientContext: clientContext
+ )
+ let deviceJWT = try identity.signedDeviceJWT(
+ clientIdentifier: accountStorage.clientIdentifier
+ )
+ signInCode = pin.code.uppercased()
+
+ for _ in 0..<150 {
+ try Task.checkCancellation()
+ try await Task.sleep(for: .seconds(2))
+ let currentPin = try await authClient.fetchPin(
+ id: String(pin.id),
+ deviceJWT: deviceJWT,
+ clientContext: clientContext
+ )
+ guard let userToken = currentPin.authToken?.nilIfBlank else { continue }
+ let preparedToken = try await accountJWTManager.acceptNewAccountToken(
+ userToken,
+ registeredKeyID: identity.keyID
+ )
+ scheduleAccountTokenRefresh(preparedToken)
+ let servers = try await fetchAuthorizedServers()
+ isPairing = false
+ signInCode = nil
+ await presentDiscoveredServers(servers, preferStoredSelection: true)
+ return
+ }
+
+ isPairing = false
+ signInCode = nil
+ errorMessage = "The Plex link code expired. Start sign-in again for a new code."
+ } catch is CancellationError {
+ return
+ } catch {
+ guard !Task.isCancelled else { return }
+ isPairing = false
+ signInCode = nil
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ func selectServer(_ server: PlexServerResource) async {
+ guard !server.connections.isEmpty else {
+ errorMessage = "\(server.name) has no available connections."
+ return
+ }
+ availableServers = []
+ if await connect(to: server) {
+ defaults.set(server.id, forKey: DefaultsKey.selectedServerIdentifier)
+ }
+ }
+
+ func reconnectAuthorizedAccount() async {
+ guard hasAuthorizedAccount else {
+ startPlexDeviceAuthorization()
+ return
+ }
+
+ connectionState = .connecting
+ availableServers = []
+ errorMessage = nil
+ do {
+ let servers = try await fetchAuthorizedServers()
+ await presentDiscoveredServers(servers, preferStoredSelection: true)
+ } catch {
+ connection = nil
+ connectionState = .disconnected
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ func chooseServer() async {
+ guard hasAuthorizedAccount else {
+ startPlexDeviceAuthorization()
+ return
+ }
+
+ do {
+ let servers = try await fetchAuthorizedServers()
+ availableServers = servers
+ playbackRequest = nil
+ connection = nil
+ connectionState = .disconnected
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ func refreshAll() async {
+ guard let connection else { return }
+ let refreshID = UUID()
+ homeRefreshID = refreshID
+ isLoadingHome = true
+ isLoadingLibraries = true
+ errorMessage = nil
+
+ async let homeResult: Result<[PlexHub], Error> = {
+ do { return .success(try await client.fetchHome(connection: connection)) }
+ catch { return .failure(error) }
+ }()
+ async let librariesResult: Result<[TVPlexLibrary], Error> = {
+ do { return .success(try await client.fetchLibraries(connection: connection)) }
+ catch { return .failure(error) }
+ }()
+ let (home, libraryList) = await (homeResult, librariesResult)
+ guard homeRefreshID == refreshID, self.connection == connection else { return }
+
+ switch home {
+ case .success(let hubs):
+ homeHubs = hubs
+ topShelfPublisher.publish(hubs: homeHubs, connection: connection, client: client)
+ case .failure(let error):
+ errorMessage = error.localizedDescription
+ }
+ switch libraryList {
+ case .success(let values):
+ libraries = values
+ case .failure(let error):
+ errorMessage = error.localizedDescription
+ }
+ isLoadingHome = false
+ isLoadingLibraries = false
+ }
+
+ func libraryBrowseDefinition(_ library: TVPlexLibrary) async throws -> PlexLibraryBrowseDefinition {
+ guard let connection else { throw TVPlexError.notConnected }
+ let definition = try await client.fetchLibraryBrowseDefinition(library, connection: connection)
+ guard self.connection == connection else { throw CancellationError() }
+ return definition
+ }
+
+ func libraryFilterValues(_ filter: PlexLibraryFilterDefinition) async throws -> [PlexLibraryFilterValue] {
+ guard let connection else { throw TVPlexError.notConnected }
+ let values = try await client.fetchLibraryFilterValues(filter, connection: connection)
+ guard self.connection == connection else { throw CancellationError() }
+ return values
+ }
+
+ func loadLibrary(_ library: TVPlexLibrary, options: PlexLibraryBrowseOptions = .default, refresh: Bool = false) async {
+ guard let connection else { return }
+ let query = LibraryQuery(connection: connection, options: options)
+ let queryChanged = libraryQueries[library.id] != query
+ if !queryChanged, !refresh, libraryItems[library.id] != nil { return }
+ let requestID = UUID()
+ libraryRequestIDs[library.id] = requestID
+ libraryQueries[library.id] = query
+ libraryErrors[library.id] = nil
+ libraryPaginationFailureIDs.remove(library.id)
+ if queryChanged {
+ libraryItems[library.id] = nil
+ libraryTotalSizes[library.id] = nil
+ libraryNextOffsets[library.id] = nil
+ }
+ defer {
+ if libraryRequestIDs[library.id] == requestID { libraryRequestIDs[library.id] = nil }
+ }
+ do {
+ let page = try await client.fetchLibrary(library, options: options, connection: connection)
+ try Task.checkCancellation()
+ guard libraryRequestIDs[library.id] == requestID, self.connection == connection else { return }
+ libraryItems[library.id] = page.items
+ libraryNextOffsets[library.id] = page.offset + page.items.count
+ libraryTotalSizes[library.id] = page.totalSize ?? page.items.count
+ } catch is CancellationError {
+ return
+ } catch {
+ guard !Task.isCancelled else { return }
+ guard libraryRequestIDs[library.id] == requestID, self.connection == connection else { return }
+ libraryErrors[library.id] = error.localizedDescription
+ }
+ }
+
+ func loadMoreLibraryItems(_ library: TVPlexLibrary, currentItem: PlexMediaItem) async {
+ guard let connection,
+ !isLoading(library),
+ let query = libraryQueries[library.id], query.connection == connection,
+ let items = libraryItems[library.id], items.last?.id == currentItem.id,
+ let offset = libraryNextOffsets[library.id],
+ offset < (libraryTotalSizes[library.id] ?? offset) else { return }
+ let requestID = UUID()
+ libraryRequestIDs[library.id] = requestID
+ libraryErrors[library.id] = nil
+ defer {
+ if libraryRequestIDs[library.id] == requestID { libraryRequestIDs[library.id] = nil }
+ }
+ do {
+ let page = try await client.fetchLibrary(library, start: offset, options: query.options, connection: connection)
+ try Task.checkCancellation()
+ guard libraryRequestIDs[library.id] == requestID, self.connection == connection else { return }
+ guard page.offset == offset, !page.items.isEmpty else { throw PlexAPIError.invalidResponse }
+ let existingIDs = Set(items.map(\.id))
+ libraryItems[library.id] = items + page.items.filter { !existingIDs.contains($0.id) }
+ libraryNextOffsets[library.id] = page.offset + page.items.count
+ libraryTotalSizes[library.id] = page.totalSize ?? libraryTotalSizes[library.id]
+ libraryPaginationFailureIDs.remove(library.id)
+ } catch is CancellationError {
+ return
+ } catch {
+ guard !Task.isCancelled else { return }
+ guard libraryRequestIDs[library.id] == requestID, self.connection == connection else { return }
+ libraryPaginationFailureIDs.insert(library.id)
+ libraryErrors[library.id] = error.localizedDescription
+ }
+ }
+
+ func retryLibrary(_ library: TVPlexLibrary, options: PlexLibraryBrowseOptions) async {
+ if libraryPaginationFailureIDs.contains(library.id), let last = libraryItems[library.id]?.last {
+ await loadMoreLibraryItems(library, currentItem: last)
+ } else {
+ await loadLibrary(library, options: options, refresh: true)
+ }
+ }
+
+ func isLoading(_ library: TVPlexLibrary) -> Bool {
+ libraryRequestIDs[library.id] != nil
+ }
+
+ func submitSearch() {
+ searchTask?.cancel()
+ searchErrorMessage = nil
+ searchHubs = []
+ let query = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !query.isEmpty, let connection else {
+ searchHubs = []
+ isSearching = false
+ return
+ }
+ isSearching = true
+ searchTask = Task {
+ do {
+ try await Task.sleep(for: .milliseconds(250))
+ let hubs = try await client.search(query: query, connection: connection)
+ guard !Task.isCancelled, self.connection == connection,
+ query == searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) else {
+ return
+ }
+ searchHubs = hubs
+ isSearching = false
+ } catch is CancellationError {
+ return
+ } catch {
+ guard !Task.isCancelled, self.connection == connection,
+ query == searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) else { return }
+ searchErrorMessage = error.localizedDescription
+ isSearching = false
+ }
+ }
+ }
+
+ func resolvedItem(_ item: PlexMediaItem) async throws -> PlexMediaItem {
+ guard let connection else { throw TVPlexError.notConnected }
+ return try await client.fetchMetadata(ratingKey: item.ratingKey, connection: connection)
+ }
+
+ func hubPage(path: String, start: Int) async throws -> PlexMediaPage {
+ guard let connection else { throw TVPlexError.notConnected }
+ let page = try await client.fetchHubPage(path: path, start: start, connection: connection)
+ try Task.checkCancellation()
+ guard self.connection == connection else { throw CancellationError() }
+ return page
+ }
+
+ func mediaExtras(for item: PlexMediaItem) async throws -> [PlexMediaItem] {
+ guard item.supportsMediaExtras else { return [] }
+ guard let connection else { throw TVPlexError.notConnected }
+ let extras = try await client.fetchMediaExtras(ratingKey: item.ratingKey, connection: connection)
+ guard self.connection == connection else { throw CancellationError() }
+ return extras
+ }
+
+ func relatedHubs(for item: PlexMediaItem) async throws -> [PlexHub] {
+ guard let connection else { throw TVPlexError.notConnected }
+ let hubs = try await client.fetchRelatedHubs(ratingKey: item.ratingKey, connection: connection)
+ guard self.connection == connection else { throw CancellationError() }
+ return hubs
+ }
+
+ func episodeSeriesCast(for item: PlexMediaItem) async throws -> [PlexTag] {
+ guard let connection else { throw TVPlexError.notConnected }
+ return try await client.fetchEpisodeSeriesCast(for: item, connection: connection)
+ }
+
+ func children(of item: PlexMediaItem) async throws -> [PlexMediaItem] {
+ guard let connection else { throw TVPlexError.notConnected }
+ return try await client.fetchChildren(of: item, connection: connection)
+ }
+
+ func seasonEpisodes(ratingKey: String) async -> [PlexMediaItem] {
+ guard let connection else { return [] }
+ do {
+ let episodes = try await client.fetchChildren(ratingKey: ratingKey, connection: connection)
+ try Task.checkCancellation()
+ return episodes.filter { $0.type?.lowercased() == "episode" }
+ } catch is CancellationError {
+ return []
+ } catch {
+ guard !Task.isCancelled else { return [] }
+ errorMessage = error.localizedDescription
+ return []
+ }
+ }
+
+ func seriesSeasons(ratingKey: String) async -> [PlexMediaItem] {
+ guard let connection else { return [] }
+ do {
+ let seasons = try await client.fetchChildren(ratingKey: ratingKey, connection: connection)
+ try Task.checkCancellation()
+ return seasons.filter { $0.type?.lowercased() == "season" }
+ } catch is CancellationError {
+ return []
+ } catch {
+ guard !Task.isCancelled else { return [] }
+ errorMessage = error.localizedDescription
+ return []
+ }
+ }
+
+ func personDetails(for route: PlexPersonRoute) async throws -> TVPlexPersonDetails {
+ guard let connection else {
+ throw TVPlexError.notConnected
+ }
+
+ async let person = client.fetchPerson(
+ identifier: route.identifier,
+ connection: connection
+ )
+ async let media = client.fetchPersonMedia(
+ identifier: route.identifier,
+ connection: connection
+ )
+ return try await TVPlexPersonDetails(person: person, media: media)
+ }
+
+ func play(
+ _ item: PlexMediaItem,
+ source: PlexPlaybackSource? = nil,
+ resume: Bool = true
+ ) {
+ play(
+ item,
+ source: source,
+ resume: resume,
+ playbackRate: .normal
+ )
+ }
+
+ func play(
+ _ item: PlexMediaItem,
+ source: PlexPlaybackSource? = nil,
+ resume: Bool,
+ playbackRate: PlexPlaybackRate
+ ) {
+ preparePlayback(for: item, kind: .content) { [weak self] in
+ guard let self else { throw CancellationError() }
+ return try await initialPlaybackRequest(
+ for: item,
+ source: source,
+ resume: resume,
+ playbackRate: playbackRate
+ )
+ }
+ }
+
+ func playPrimaryExtra(for item: PlexMediaItem) {
+ guard let path = item.primaryExtraKey?.nilIfBlank else {
+ errorMessage = PlexAPIError.invalidResponse.localizedDescription
+ return
+ }
+ preparePlayback(for: item, kind: .primaryExtra) { [weak self] in
+ guard let self else { throw CancellationError() }
+ return try await primaryExtraPlaybackRequest(path: path)
+ }
+ }
+
+ func isPreparingPlayback(
+ _ item: PlexMediaItem,
+ kind: TVPlaybackPreparationKind = .content
+ ) -> Bool {
+ playbackPreparation == TVPlaybackPreparation(
+ itemRatingKey: item.ratingKey,
+ kind: kind
+ )
+ }
+
+ private func preparePlayback(
+ for item: PlexMediaItem,
+ kind: TVPlaybackPreparationKind,
+ request: @escaping @MainActor () async throws -> TVPlexPlaybackRequest
+ ) {
+ playbackPreparationTask?.cancel()
+ let preparationID = UUID()
+ playbackPreparationID = preparationID
+ playbackPreparation = TVPlaybackPreparation(
+ itemRatingKey: item.ratingKey,
+ kind: kind
+ )
+ errorMessage = nil
+ playbackPreparationTask = Task { [weak self] in
+ guard let self else { return }
+ defer {
+ if playbackPreparationID == preparationID {
+ playbackPreparation = nil
+ playbackPreparationTask = nil
+ }
+ }
+ do {
+ let playbackRequest = try await request()
+ try Task.checkCancellation()
+ guard playbackPreparationID == preparationID else { return }
+ self.playbackRequest = playbackRequest
+ } catch is CancellationError {
+ return
+ } catch {
+ guard !Task.isCancelled else { return }
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ func playResolved(
+ _ item: PlexMediaItem,
+ queue: PlexPlaybackQueue? = nil,
+ resume: Bool,
+ playbackRate: PlexPlaybackRate
+ ) {
+ cancelPlaybackPreparation()
+ guard item.isPlayable else {
+ errorMessage = "Choose a movie, episode, track, or trailer to play."
+ return
+ }
+ playbackRequest = TVPlexPlaybackRequest(
+ item: item,
+ queue: queue,
+ startTime: resume ? item.resumeSeconds : 0,
+ playbackRate: playbackRate
+ )
+ }
+
+ func presentPlayback(_ request: TVPlexPlaybackRequest) {
+ cancelPlaybackPreparation()
+ playbackRequest = request
+ }
+
+ func changeVideoQuality(
+ to quality: PlexVideoQuality,
+ for item: PlexMediaItem,
+ queue: PlexPlaybackQueue?,
+ source: PlexPlaybackSource?,
+ queueSourcePreference: PlexPlaybackQueueSourcePreference?,
+ at position: TimeInterval,
+ autoplay: Bool,
+ playbackRate: PlexPlaybackRate,
+ forceVideoTranscode: Bool
+ ) {
+ playbackRequest = TVPlexPlaybackRequest(
+ item: item,
+ queue: queue,
+ source: source,
+ queueSourcePreference: queueSourcePreference,
+ startTime: PlexPlaybackSeek.clamped(position, duration: item.durationSeconds),
+ autoplay: autoplay,
+ playbackRate: playbackRate,
+ videoQualityOverride: quality,
+ forceVideoTranscode: forceVideoTranscode
+ )
+ }
+
+ func changeVideoConversionMode(
+ forceVideoTranscode: Bool,
+ videoQualityOverride: PlexVideoQuality?,
+ for item: PlexMediaItem,
+ queue: PlexPlaybackQueue?,
+ source: PlexPlaybackSource?,
+ queueSourcePreference: PlexPlaybackQueueSourcePreference?,
+ at position: TimeInterval,
+ autoplay: Bool,
+ playbackRate: PlexPlaybackRate
+ ) {
+ playbackRequest = TVPlexPlaybackRequest(
+ item: item,
+ queue: queue,
+ source: source,
+ queueSourcePreference: queueSourcePreference,
+ startTime: PlexPlaybackSeek.clamped(position, duration: item.durationSeconds),
+ autoplay: autoplay,
+ playbackRate: playbackRate,
+ videoQualityOverride: videoQualityOverride,
+ forceVideoTranscode: forceVideoTranscode
+ )
+ }
+
+ func changeMusicQuality(
+ to quality: PlexMusicQuality,
+ for item: PlexMediaItem,
+ queue: PlexPlaybackQueue?,
+ source: PlexPlaybackSource?,
+ queueSourcePreference: PlexPlaybackQueueSourcePreference?,
+ at position: TimeInterval,
+ autoplay: Bool,
+ playbackRate: PlexPlaybackRate,
+ videoQualityOverride: PlexVideoQuality?,
+ forceVideoTranscode: Bool
+ ) {
+ guard connection?.kind != .local else { return }
+ remoteMusicQuality = quality
+ playbackRequest = TVPlexPlaybackRequest(
+ item: item,
+ queue: queue,
+ source: source,
+ queueSourcePreference: queueSourcePreference,
+ startTime: PlexPlaybackSeek.clamped(position, duration: item.durationSeconds),
+ autoplay: autoplay,
+ playbackRate: playbackRate,
+ videoQualityOverride: videoQualityOverride,
+ forceVideoTranscode: forceVideoTranscode
+ )
+ }
+
+ func changeAudioBoost(
+ to audioBoost: PlexAudioBoost,
+ for item: PlexMediaItem,
+ queue: PlexPlaybackQueue?,
+ source: PlexPlaybackSource?,
+ queueSourcePreference: PlexPlaybackQueueSourcePreference?,
+ at position: TimeInterval,
+ autoplay: Bool,
+ playbackRate: PlexPlaybackRate,
+ videoQualityOverride: PlexVideoQuality?,
+ forceVideoTranscode: Bool
+ ) {
+ self.audioBoost = audioBoost
+ playbackRequest = TVPlexPlaybackRequest(
+ item: item,
+ queue: queue,
+ source: source,
+ queueSourcePreference: queueSourcePreference,
+ startTime: PlexPlaybackSeek.clamped(position, duration: item.durationSeconds),
+ autoplay: autoplay,
+ playbackRate: playbackRate,
+ videoQualityOverride: videoQualityOverride,
+ forceVideoTranscode: forceVideoTranscode
+ )
+ }
+
+ func changeSubtitleAutoSync(
+ isEnabled: Bool,
+ for item: PlexMediaItem,
+ queue: PlexPlaybackQueue?,
+ source: PlexPlaybackSource?,
+ queueSourcePreference: PlexPlaybackQueueSourcePreference?,
+ at position: TimeInterval,
+ autoplay: Bool,
+ playbackRate: PlexPlaybackRate,
+ videoQualityOverride: PlexVideoQuality?,
+ forceVideoTranscode: Bool
+ ) {
+ automaticallySyncSubtitles = isEnabled
+ playbackRequest = TVPlexPlaybackRequest(
+ item: item,
+ queue: queue,
+ source: source,
+ queueSourcePreference: queueSourcePreference,
+ startTime: PlexPlaybackSeek.clamped(position, duration: item.durationSeconds),
+ autoplay: autoplay,
+ playbackRate: playbackRate,
+ videoQualityOverride: videoQualityOverride,
+ forceVideoTranscode: forceVideoTranscode
+ )
+ }
+
+ func changeSubtitleSize(
+ to subtitleSize: PlexSubtitleSize,
+ for item: PlexMediaItem,
+ queue: PlexPlaybackQueue?,
+ source: PlexPlaybackSource?,
+ queueSourcePreference: PlexPlaybackQueueSourcePreference?,
+ at position: TimeInterval,
+ autoplay: Bool,
+ playbackRate: PlexPlaybackRate,
+ videoQualityOverride: PlexVideoQuality?,
+ forceVideoTranscode: Bool
+ ) {
+ self.subtitleSize = subtitleSize
+ playbackRequest = TVPlexPlaybackRequest(
+ item: item,
+ queue: queue,
+ source: source,
+ queueSourcePreference: queueSourcePreference,
+ startTime: PlexPlaybackSeek.clamped(position, duration: item.durationSeconds),
+ autoplay: autoplay,
+ playbackRate: playbackRate,
+ videoQualityOverride: videoQualityOverride,
+ forceVideoTranscode: forceVideoTranscode
+ )
+ }
+
+ func selectVideoScalingMode(_ scalingMode: PlexVideoScalingMode) {
+ videoScalingMode = scalingMode
+ }
+
+ func setPlaybackSleepTimer(_ preset: PlexPlaybackSleepTimerPreset) {
+ let timer = PlexPlaybackSleepTimer(preset: preset)
+ guard timer != playbackSleepTimer else { return }
+ playbackSleepTimer = timer
+ }
+
+ func clearPlaybackSleepTimer() {
+ guard playbackSleepTimer.isActive else { return }
+ playbackSleepTimer = .off
+ }
+
+ func setPlaybackMarkerBehavior(
+ _ behavior: PlexPlaybackMarkerBehavior,
+ for kind: PlexPlaybackMarkerKind
+ ) {
+ switch kind {
+ case .intro:
+ skipIntroBehavior = behavior
+ case .commercial:
+ skipAdsBehavior = behavior
+ case .credits:
+ skipCreditsBehavior = behavior
+ }
+ }
+
+ func restartPlayback(
+ of item: PlexMediaItem,
+ queue: PlexPlaybackQueue?,
+ source: PlexPlaybackSource? = nil,
+ queueSourcePreference: PlexPlaybackQueueSourcePreference? = nil,
+ autoplay: Bool = true,
+ playbackRate: PlexPlaybackRate = .normal,
+ videoQualityOverride: PlexVideoQuality? = nil,
+ forceVideoTranscode: Bool = false
+ ) {
+ playbackRequest = TVPlexPlaybackRequest(
+ item: item,
+ queue: queue,
+ source: source,
+ queueSourcePreference: queueSourcePreference,
+ startTime: 0,
+ autoplay: autoplay,
+ playbackRate: playbackRate,
+ videoQualityOverride: videoQualityOverride,
+ forceVideoTranscode: forceVideoTranscode
+ )
+ }
+
+ func dismissPlayer() {
+ cancelPlaybackPreparation()
+ clearPlaybackSleepTimer()
+ playbackRequest = nil
+ }
+
+ private func cancelPlaybackPreparation() {
+ playbackPreparationTask?.cancel()
+ playbackPreparationTask = nil
+ playbackPreparationID = UUID()
+ playbackPreparation = nil
+ }
+
+ func artworkURL(path: String?, width: Int, height: Int, usesOriginalImage: Bool = false) async -> URL? {
+ guard let connection else { return nil }
+ return await client.artworkURL(
+ path: path, width: width, height: height,
+ connection: connection, usesOriginalImage: usesOriginalImage
+ )
+ }
+
+ func playbackArtworkData(for item: PlexMediaItem) async -> Data? {
+ guard let connection,
+ let path = item.nowPlayingArtworkPaths.first else {
+ return nil
+ }
+ return try? await client.fetchArtworkData(
+ path: path,
+ width: 1_200,
+ height: 1_200,
+ connection: connection
+ )
+ }
+
+ func contentProposalArtworkData(for item: PlexMediaItem) async -> Data? {
+ guard let connection,
+ let path = item.contentProposalArtworkPaths.first else {
+ return nil
+ }
+ return try? await client.fetchArtworkData(
+ path: path,
+ width: 1_280,
+ height: 720,
+ connection: connection
+ )
+ }
+
+ func playbackChapterArtwork(
+ for chapters: [PlexPlaybackChapter]
+ ) async -> [String: Data] {
+ guard let connection else { return [:] }
+ let client = client
+
+ let requests = chapters.compactMap { chapter in
+ chapter.thumbnailPath.map { path in
+ (chapterID: chapter.id, path: path)
+ }
+ }
+ let artwork: [(chapterID: String, data: Data)] = await PlexBoundedConcurrentMap.compactMap(
+ requests,
+ maximumConcurrentTasks: 4
+ ) { request in
+ guard let data = try? await client.fetchArtworkData(
+ path: request.path,
+ width: 640,
+ height: 360,
+ connection: connection
+ ) else {
+ return nil
+ }
+ return (chapterID: request.chapterID, data: data)
+ }
+
+ return artwork.reduce(into: [:]) { result, element in
+ result[element.chapterID] = element.data
+ }
+ }
+
+ func playbackPlan(
+ for request: TVPlexPlaybackRequest,
+ recovery: PlexPlaybackRecoveryRequest? = nil
+ ) async throws -> PlexPlaybackPlan {
+ guard let connection else { throw TVPlexError.invalidServerURL }
+ return try await client.playbackPlan(
+ for: request.item,
+ startTime: recovery?.startTime ?? request.startTime,
+ sessionIdentifier: request.sessionIdentifier,
+ videoQuality: recovery?.videoQuality
+ ?? request.videoQualityOverride
+ ?? activeVideoQuality,
+ musicQuality: activeMusicQuality,
+ audioBoost: audioBoost,
+ streamingPolicy: playbackStreamingPolicy,
+ subtitleBurnMode: subtitleBurnMode,
+ subtitleSize: subtitleSize,
+ automaticallySyncSubtitles: automaticallySyncSubtitles,
+ automaticallyAdjustVideoQuality: automaticallyAdjustVideoQuality,
+ playSmallerVideosAtOriginalQuality: connection.kind == .local
+ || playSmallerVideosAtOriginalQuality,
+ forceVideoTranscode: request.forceVideoTranscode,
+ source: recovery?.source ?? request.source,
+ forceServerMediaSelection: recovery?.forceServerMediaSelection ?? false,
+ connection: connection
+ )
+ }
+
+ private func setVideoQuality(
+ _ quality: PlexVideoQuality,
+ for connectionKind: PlexConnectionKind?
+ ) {
+ if connectionKind == .local {
+ localVideoQuality = quality
+ } else {
+ remoteVideoQuality = quality
+ }
+ }
+
+ func selectMediaStreams(
+ partID: Int,
+ audioStreamID: Int? = nil,
+ subtitleStreamID: Int? = nil,
+ for item: PlexMediaItem
+ ) async throws -> PlexMediaItem {
+ guard let connection else { throw TVPlexError.invalidServerURL }
+ try await client.selectMediaStreams(
+ partID: partID,
+ audioStreamID: audioStreamID,
+ subtitleStreamID: subtitleStreamID,
+ connection: connection
+ )
+ return try await client.fetchMetadata(ratingKey: item.ratingKey, connection: connection)
+ }
+
+ func setSubtitleOffset(
+ streamID: Int,
+ milliseconds: Int,
+ for item: PlexMediaItem
+ ) async throws -> PlexMediaItem {
+ guard let connection else { throw TVPlexError.invalidServerURL }
+ try await client.setSubtitleOffset(
+ streamID: streamID,
+ milliseconds: milliseconds,
+ connection: connection
+ )
+ return try await client.fetchMetadata(ratingKey: item.ratingKey, connection: connection)
+ }
+
+ func replacePlayback(
+ with item: PlexMediaItem,
+ queue: PlexPlaybackQueue?,
+ source: PlexPlaybackSource?,
+ queueSourcePreference: PlexPlaybackQueueSourcePreference?,
+ at position: TimeInterval,
+ autoplay: Bool,
+ playbackRate: PlexPlaybackRate,
+ videoQualityOverride: PlexVideoQuality? = nil,
+ forceVideoTranscode: Bool = false
+ ) {
+ playbackRequest = TVPlexPlaybackRequest(
+ item: item,
+ queue: queue,
+ source: source,
+ queueSourcePreference: queueSourcePreference,
+ startTime: PlexPlaybackSeek.clamped(position, duration: item.durationSeconds),
+ autoplay: autoplay,
+ playbackRate: playbackRate,
+ videoQualityOverride: videoQualityOverride,
+ forceVideoTranscode: forceVideoTranscode
+ )
+ }
+
+ func changePlaybackVersion(
+ to source: PlexPlaybackSource,
+ for item: PlexMediaItem,
+ queue: PlexPlaybackQueue?,
+ queueSourcePreference: PlexPlaybackQueueSourcePreference?,
+ at position: TimeInterval,
+ autoplay: Bool,
+ playbackRate: PlexPlaybackRate,
+ videoQualityOverride: PlexVideoQuality?,
+ forceVideoTranscode: Bool
+ ) {
+ guard item.playbackSource(mediaIndex: source.mediaIndex) == source else {
+ errorMessage = PlexAPIError.noPlayableMedia.localizedDescription
+ return
+ }
+ let updatedQueueSourcePreference: PlexPlaybackQueueSourcePreference?
+ if queue?.isCurrentCinemaPreplayItem == true {
+ updatedQueueSourcePreference = queueSourcePreference
+ } else if queue != nil {
+ updatedQueueSourcePreference = PlexPlaybackQueueSourcePreference(
+ ratingKey: item.ratingKey,
+ source: source
+ )
+ } else {
+ updatedQueueSourcePreference = nil
+ }
+ replacePlayback(
+ with: item,
+ queue: queue,
+ source: source,
+ queueSourcePreference: updatedQueueSourcePreference,
+ at: position,
+ autoplay: autoplay,
+ playbackRate: playbackRate,
+ videoQualityOverride: videoQualityOverride,
+ forceVideoTranscode: forceVideoTranscode
+ )
+ }
+
+ func reportPlayback(_ update: PlexTimelineUpdate) async -> PlexTimelineResponse? {
+ guard let connection else { return nil }
+ let response = await client.reportTimeline(update, connection: connection)
+ // Read server metadata only after the final position report has completed.
+ // Dismissing AVKit happens before that asynchronous write finishes.
+ if update.state == .stopped, update.continuing == false, self.connection == connection {
+ playbackMetadataRevision = UUID()
+ Task { await refreshAll() }
+ }
+ return response
+ }
+
+ func markWatched(_ item: PlexMediaItem) async {
+ guard let connection else { return }
+ await client.markWatched(item, connection: connection)
+ guard self.connection == connection else { return }
+ await refreshAll()
+ }
+
+ func nextEpisode(after item: PlexMediaItem) async -> PlexMediaItem? {
+ guard autoplayNextEpisode,
+ item.type?.lowercased() == "episode",
+ let connection else {
+ return nil
+ }
+ do {
+ return try await client.fetchNextEpisode(after: item, connection: connection)
+ } catch {
+ errorMessage = error.localizedDescription
+ return nil
+ }
+ }
+
+ func resolvedNextEpisode(after item: PlexMediaItem) async -> PlexMediaItem? {
+ guard item.type?.lowercased() == "episode",
+ let connection,
+ let nextItem = try? await client.fetchNextEpisode(
+ after: item,
+ connection: connection
+ ) else {
+ return nil
+ }
+ return try? await client.fetchMetadata(
+ ratingKey: nextItem.ratingKey,
+ connection: connection
+ )
+ }
+
+ func preparedQueueAdvance(
+ from request: TVPlexPlaybackRequest,
+ direction: PlexPlaybackQueueDirection,
+ autoplay: Bool,
+ playbackRate: PlexPlaybackRate
+ ) async throws -> TVPlexPlaybackRequest {
+ guard let connection,
+ var queue = request.queue,
+ queue.canMove(direction) else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+ if queue.needsWindowRefresh(for: direction) {
+ guard let currentQueueItemID = queue.currentItem.playQueueItemID else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+ let page = try await client.fetchPlayQueuePage(
+ queueID: queue.id,
+ centeredOn: currentQueueItemID,
+ connection: connection
+ )
+ try queue.replaceWindow(
+ with: page,
+ centeredOn: currentQueueItemID
+ )
+ }
+ guard let queuedItem = queue.move(direction) else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+ let item = try await client.fetchMetadata(
+ ratingKey: queuedItem.ratingKey,
+ connection: connection
+ )
+ return TVPlexPlaybackRequest(
+ item: item,
+ queue: queue,
+ source: request.queueSourcePreference?.source(for: item),
+ queueSourcePreference: request.queueSourcePreference,
+ startTime: !queue.isCinemaPreplayQueue && direction == .next
+ ? item.resumeSeconds
+ : 0,
+ autoplay: autoplay,
+ playbackRate: playbackRate,
+ videoQualityOverride: request.videoQualityOverride
+ )
+ }
+
+ func preparedQueueSelection(
+ from request: TVPlexPlaybackRequest,
+ playQueueItemID: String,
+ autoplay: Bool,
+ playbackRate: PlexPlaybackRate
+ ) async throws -> TVPlexPlaybackRequest {
+ guard let connection,
+ var queue = request.queue,
+ queue.presentation.upcomingItems.contains(where: {
+ $0.playQueueItemID == playQueueItemID
+ }),
+ let queuedItem = queue.move(toPlayQueueItemID: playQueueItemID) else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+ let item = try await client.fetchMetadata(
+ ratingKey: queuedItem.ratingKey,
+ connection: connection
+ )
+ return TVPlexPlaybackRequest(
+ item: item,
+ queue: queue,
+ source: request.queueSourcePreference?.source(for: item),
+ queueSourcePreference: request.queueSourcePreference,
+ startTime: queue.isCinemaPreplayQueue ? 0 : item.resumeSeconds,
+ autoplay: autoplay,
+ playbackRate: playbackRate,
+ videoQualityOverride: request.videoQualityOverride
+ )
+ }
+
+ func playbackRequest(
+ bySettingQueueShuffled shuffled: Bool,
+ from request: TVPlexPlaybackRequest
+ ) async throws -> TVPlexPlaybackRequest {
+ guard let connection,
+ var queue = request.queue,
+ queue.canChangeShuffle,
+ queue.isShuffled != shuffled else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+ let page = try await client.setPlayQueueShuffled(
+ shuffled,
+ queueID: queue.id,
+ connection: connection
+ )
+ try queue.applyShuffleMutation(page, expectedShuffled: shuffled)
+ return request.withQueue(queue)
+ }
+
+ func playbackRequest(
+ byRemovingQueueItem playQueueItemID: String,
+ from request: TVPlexPlaybackRequest
+ ) async throws -> TVPlexPlaybackRequest {
+ guard let connection,
+ var queue = request.queue,
+ queue.canRemoveUpcomingItem(playQueueItemID: playQueueItemID) else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+ let page = try await client.removePlayQueueItem(
+ queueID: queue.id,
+ playQueueItemID: playQueueItemID,
+ connection: connection
+ )
+ try queue.applyRemoval(
+ page,
+ removedPlayQueueItemID: playQueueItemID
+ )
+ return request.withQueue(queue)
+ }
+
+ func playbackRequest(
+ byMovingQueueItem playQueueItemID: String,
+ direction: PlexPlayQueueItemMoveDirection,
+ from request: TVPlexPlaybackRequest
+ ) async throws -> TVPlexPlaybackRequest {
+ guard let connection,
+ var queue = request.queue,
+ let move = queue.moveRequest(
+ for: playQueueItemID,
+ direction: direction
+ ) else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+ let page = try await client.movePlayQueueItem(
+ queueID: queue.id,
+ move: move,
+ connection: connection
+ )
+ try queue.applyMove(page, request: move)
+ return request.withQueue(queue)
+ }
+
+ func preparedRepeatedQueue(
+ from request: TVPlexPlaybackRequest,
+ playbackRate: PlexPlaybackRate
+ ) async throws -> TVPlexPlaybackRequest {
+ guard let connection,
+ var queue = request.queue,
+ queue.canRepeatAll else {
+ throw PlexAPIError.invalidPlayQueue
+ }
+ let page = try await client.resetPlayQueue(
+ queueID: queue.id,
+ connection: connection
+ )
+ try queue.applyReset(page)
+ let item = try await client.fetchMetadata(
+ ratingKey: queue.currentItem.ratingKey,
+ connection: connection
+ )
+ return TVPlexPlaybackRequest(
+ item: item,
+ queue: queue,
+ source: request.queueSourcePreference?.source(for: item),
+ queueSourcePreference: request.queueSourcePreference,
+ startTime: 0,
+ playbackRate: playbackRate,
+ videoQualityOverride: request.videoQualityOverride
+ )
+ }
+
+ func preparedNextPlayback(
+ after request: TVPlexPlaybackRequest,
+ playbackRate: PlexPlaybackRate
+ ) async throws -> TVPlexPlaybackRequest? {
+ if request.queue?.canMoveNext == true {
+ return try await preparedQueueAdvance(
+ from: request,
+ direction: .next,
+ autoplay: true,
+ playbackRate: playbackRate
+ )
+ }
+ guard request.queue == nil,
+ request.item.type?.lowercased() == "episode",
+ let connection,
+ let nextItem = try await client.fetchNextEpisode(
+ after: request.item,
+ connection: connection
+ ) else {
+ return nil
+ }
+ let item = try await client.fetchMetadata(
+ ratingKey: nextItem.ratingKey,
+ connection: connection
+ )
+ return TVPlexPlaybackRequest(
+ item: item,
+ source: request.queueSourcePreference?.source(for: item),
+ queueSourcePreference: request.queueSourcePreference,
+ startTime: item.resumeSeconds,
+ playbackRate: playbackRate,
+ videoQualityOverride: request.videoQualityOverride
+ )
+ }
+
+ func logout() async {
+ sessionRevision = UUID()
+ pendingTopShelfRoute = nil
+ topShelfRouteTask?.cancel()
+ topShelfPublisher.clear()
+ connection = nil
+ searchTask?.cancel()
+ signInTask?.cancel()
+ accountTokenRefreshTask?.cancel()
+ cancelPlaybackPreparation()
+ do {
+ try await accountStorage.persistAccountToken("")
+ try await keychain.delete(account: KeychainAccounts.serverToken)
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ defaults.removeObject(forKey: DefaultsKey.selectedServerIdentifier)
+ connection = nil
+ connectionState = .disconnected
+ homeHubs = []
+ libraries = []
+ libraryItems = [:]
+ libraryTotalSizes = [:]
+ libraryErrors = [:]
+ libraryQueries = [:]
+ libraryRequestIDs = [:]
+ libraryNextOffsets = [:]
+ libraryPaginationFailureIDs = []
+ searchHubs = []
+ searchErrorMessage = nil
+ clearPlaybackSleepTimer()
+ playbackRequest = nil
+ signInCode = nil
+ availableServers = []
+ isPairing = false
+ startPlexDeviceAuthorization()
+ }
+
+ func openTopShelfURL(_ url: URL) {
+ guard let route = TVTopShelfRoute(url: url) else {
+ errorMessage = "This PlexBar content link is invalid."
+ return
+ }
+ topShelfRouteTask?.cancel()
+ pendingTopShelfRoute = route
+ processPendingTopShelfRoute()
+ }
+
+ private func processPendingTopShelfRoute() {
+ guard hasRestoredSession, let route = pendingTopShelfRoute else { return }
+ guard let connection else {
+ // Keep the route while the saved session reconnects or the server picker is visible.
+ if !hasAuthorizedAccount {
+ pendingTopShelfRoute = nil
+ errorMessage = "Sign in to Plex to open this title."
+ }
+ return
+ }
+ pendingTopShelfRoute = nil
+ guard route.serverIdentifier == connection.serverIdentifier else {
+ errorMessage = "This title belongs to a different Plex server. Select that server and open the title again."
+ return
+ }
+ selectedTab = .home
+ topShelfRouteTask = Task {
+ do {
+ let item = try await client.fetchMetadata(ratingKey: route.ratingKey, connection: connection)
+ try Task.checkCancellation()
+ guard self.connection == connection else { return }
+ guard item.ratingKey == route.ratingKey else { throw TVPlexError.invalidResponse }
+ homePath = [.media(item)]
+ switch route.action {
+ case .display:
+ dismissPlayer()
+ case .play:
+ play(item, resume: true)
+ }
+ } catch is CancellationError {
+ return
+ } catch {
+ guard !Task.isCancelled, self.connection == connection else { return }
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ private func fetchAuthorizedServers() async throws -> [PlexServerResource] {
+ let servers = try await performAccountRequest { token in
+ try await authClient.fetchServers(
+ userToken: token,
+ clientContext: PlexClientContext(
+ clientIdentifier: accountStorage.clientIdentifier
+ )
+ )
+ }.sorted {
+ $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
+ }
+ guard !servers.isEmpty else {
+ throw PlexAuthError.noServersFound
+ }
+ return servers
+ }
+
+ private func initialPlaybackRequest(
+ for item: PlexMediaItem,
+ source requestedSource: PlexPlaybackSource?,
+ resume: Bool,
+ playbackRate: PlexPlaybackRate
+ ) async throws -> TVPlexPlaybackRequest {
+ guard let connection else { throw TVPlexError.invalidServerURL }
+ let resolvedItem = try await client.fetchMetadata(
+ ratingKey: item.ratingKey,
+ connection: connection
+ )
+ let source: PlexPlaybackSource?
+ if let requestedSource {
+ guard resolvedItem.playbackSource(
+ mediaIndex: requestedSource.mediaIndex
+ ) == requestedSource else {
+ throw PlexAPIError.noPlayableMedia
+ }
+ source = requestedSource
+ } else {
+ source = nil
+ }
+ let startOption: PlexPlaybackStartOption = resume && resolvedItem.resumeSeconds > 0
+ ? .resume
+ : .beginning
+ let cinemaExtrasPrefixCount = PlexCinemaPreplayRequestPolicy.extrasPrefixCount(
+ for: resolvedItem,
+ startOption: startOption,
+ preference: cinemaPreplayPreference
+ )
+ let queue: PlexPlaybackQueue?
+ if let cinemaExtrasPrefixCount {
+ queue = try await client.createCinemaPlayQueue(
+ for: resolvedItem,
+ extrasPrefixCount: cinemaExtrasPrefixCount,
+ connection: connection
+ )
+ } else if resolvedItem.continuousPlayQueueType != nil {
+ queue = try await client.createContinuousPlayQueue(
+ for: resolvedItem,
+ connection: connection
+ )
+ } else {
+ queue = nil
+ }
+ let playbackItem: PlexMediaItem
+ if let queue, queue.isCurrentCinemaPreplayItem || resolvedItem.supportsHierarchyPlayback {
+ playbackItem = try await client.fetchMetadata(
+ ratingKey: queue.currentItem.ratingKey,
+ connection: connection
+ )
+ } else {
+ playbackItem = resolvedItem
+ }
+ guard playbackItem.isPlayable else { throw PlexAPIError.noPlayableMedia }
+ return TVPlexPlaybackRequest(
+ item: playbackItem,
+ queue: queue,
+ source: playbackItem.ratingKey == resolvedItem.ratingKey ? source : nil,
+ queueSourcePreference: source.map {
+ PlexPlaybackQueueSourcePreference(
+ ratingKey: resolvedItem.ratingKey,
+ source: $0
+ )
+ },
+ startTime: resume && queue?.isCurrentCinemaPreplayItem != true
+ ? playbackItem.resumeSeconds
+ : 0,
+ playbackRate: playbackRate
+ )
+ }
+
+ private func primaryExtraPlaybackRequest(
+ path: String
+ ) async throws -> TVPlexPlaybackRequest {
+ guard let connection else { throw TVPlexError.invalidServerURL }
+ let extra = try await client.fetchMetadata(
+ path: path,
+ connection: connection
+ )
+ guard extra.isPlayable else {
+ throw PlexAPIError.noPlayableMedia
+ }
+ return TVPlexPlaybackRequest(
+ item: extra,
+ startTime: 0
+ )
+ }
+
+ private func presentDiscoveredServers(
+ _ servers: [PlexServerResource],
+ preferStoredSelection: Bool
+ ) async {
+ if preferStoredSelection,
+ let selectedServerIdentifier = defaults.string(
+ forKey: DefaultsKey.selectedServerIdentifier
+ )?.nilIfBlank,
+ let selectedServer = servers.first(where: { $0.id == selectedServerIdentifier }) {
+ await selectServer(selectedServer)
+ } else if servers.count == 1, let server = servers.first {
+ await selectServer(server)
+ } else {
+ connection = nil
+ connectionState = .disconnected
+ availableServers = servers
+ }
+ }
+
+ private func performAccountRequest(
+ _ operation: (String) async throws -> Value
+ ) async throws -> Value {
+ let preparedToken = try await accountJWTManager.prepareAccountToken()
+ scheduleAccountTokenRefresh(preparedToken)
+
+ do {
+ return try await operation(preparedToken.token)
+ } catch let error as PlexAuthError where error.requiresTokenRefresh {
+ let refreshedToken = try await accountJWTManager.recoverRejectedAccountToken(
+ preparedToken.token
+ )
+ scheduleAccountTokenRefresh(refreshedToken)
+ do {
+ return try await operation(refreshedToken.token)
+ } catch let retryError as PlexAuthError where retryError.requiresTokenRefresh {
+ accountTokenRefreshTask?.cancel()
+ try await accountStorage.persistAccountToken("")
+ throw retryError
+ }
+ }
+ }
+
+ private func scheduleAccountTokenRefresh(_ preparedToken: PlexPreparedAccountToken) {
+ accountTokenRefreshTask?.cancel()
+ let delay = max(preparedToken.refreshAt.timeIntervalSinceNow, 0)
+ accountTokenRefreshTask = Task { [weak self] in
+ do {
+ try await Task.sleep(for: .seconds(delay))
+ } catch {
+ return
+ }
+ guard let self else { return }
+
+ do {
+ let refreshedToken = try await accountJWTManager.prepareAccountToken(
+ forceRefresh: true
+ )
+ scheduleAccountTokenRefresh(refreshedToken)
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+}
diff --git a/PlexBar/TV/Stores/TVHubBrowseStore.swift b/PlexBar/TV/Stores/TVHubBrowseStore.swift
new file mode 100644
index 0000000..20c5959
--- /dev/null
+++ b/PlexBar/TV/Stores/TVHubBrowseStore.swift
@@ -0,0 +1,116 @@
+import PlexModels
+import Foundation
+import Observation
+
+@MainActor
+@Observable
+final class TVHubBrowseStore {
+ private(set) var items: [PlexMediaItem] = []
+ private(set) var isLoading = false
+ private(set) var errorMessage: String?
+ private(set) var hasMore = false
+ private var nextOffset = 0
+ private var identity: Identity?
+ private var requestID: UUID?
+ private var loadedFirstPage = false
+ private let videoOnly: Bool
+
+ init(videoOnly: Bool = false) {
+ self.videoOnly = videoOnly
+ }
+
+ private func includedItems(_ items: [PlexMediaItem]) -> [PlexMediaItem] {
+ videoOnly ? TVHomeContent.videoItems(items) : items
+ }
+
+ func load(hub: PlexHub, using store: TVAppStore, refresh: Bool = false) async {
+ let identity = Identity(hub: hub, connection: store.connection)
+ if self.identity == identity, !refresh, loadedFirstPage || isLoading { return }
+ self.identity = identity
+ requestID = nil
+ items = includedItems(hub.metadata)
+ nextOffset = 0
+ loadedFirstPage = false
+ hasMore = false
+ await fetch(hub: hub, using: store)
+ }
+
+ func loadMore(hub: PlexHub, using store: TVAppStore, currentItem: PlexMediaItem? = nil) async {
+ guard !isLoading, hasMore,
+ identity == Identity(hub: hub, connection: store.connection),
+ currentItem == nil || items.last?.id == currentItem?.id else { return }
+ await fetch(hub: hub, using: store)
+ }
+
+ func retry(hub: PlexHub, using store: TVAppStore) async {
+ if loadedFirstPage {
+ await loadMore(hub: hub, using: store)
+ } else {
+ await load(hub: hub, using: store)
+ }
+ }
+
+ func visibleItems(hub: PlexHub, connection: TVPlexConnection?) -> [PlexMediaItem] {
+ identity == Identity(hub: hub, connection: connection) ? items : includedItems(hub.metadata)
+ }
+
+ /// Extend the existing collection as its trailing cards appear. Keep preview
+ /// identities and their order intact so pagination never replaces the focus target.
+ func loadInline(hub: PlexHub, using store: TVAppStore, after item: PlexMediaItem) async {
+ let expected = Identity(hub: hub, connection: store.connection)
+ guard hub.key?.nilIfBlank != nil,
+ visibleItems(hub: hub, connection: store.connection).suffix(3).contains(where: { $0.id == item.id })
+ else { return }
+ if identity != expected || !loadedFirstPage {
+ guard identity != expected || (!isLoading && errorMessage == nil),
+ hub.more || (hub.totalSize ?? hub.metadata.count) > hub.metadata.count else { return }
+ await load(hub: hub, using: store)
+ }
+ // A page can overlap the preview or the previous page. Continue through
+ // those server rows until this card has more content ahead of it.
+ while identity == expected, store.connection == expected.connection, !Task.isCancelled, !isLoading,
+ loadedFirstPage, hasMore, errorMessage == nil,
+ items.suffix(3).contains(where: { $0.id == item.id }) {
+ await loadMore(hub: hub, using: store)
+ }
+ }
+
+ private func fetch(hub: PlexHub, using store: TVAppStore) async {
+ let id = UUID()
+ requestID = id
+ isLoading = true
+ errorMessage = nil
+ defer {
+ if requestID == id { isLoading = false; requestID = nil }
+ }
+ guard let path = hub.key?.nilIfBlank else {
+ items = includedItems(hub.metadata)
+ loadedFirstPage = true
+ return
+ }
+ do {
+ let page = try await store.hubPage(path: path, start: nextOffset)
+ guard requestID == id, !Task.isCancelled else { return }
+ let endOffset = page.offset + page.items.count
+ guard page.items.isEmpty || endOffset > nextOffset else {
+ throw PlexAPIError.invalidResponse
+ }
+ var seen = Set(items.map(\.id))
+ items.append(contentsOf: includedItems(page.items).filter { seen.insert($0.id).inserted })
+ nextOffset = endOffset
+ loadedFirstPage = true
+ // Offset is measured in server rows, not deduplicated cards.
+ hasMore = !page.items.isEmpty && endOffset < (page.totalSize ?? hub.totalSize ?? endOffset)
+ } catch is CancellationError {
+ return
+ } catch {
+ guard requestID == id, !Task.isCancelled else { return }
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ struct Identity: Equatable {
+ let hub: PlexHub
+ let connection: TVPlexConnection?
+ }
+}
diff --git a/PlexBar/TV/Stores/TVPlexAccountJWTStorage.swift b/PlexBar/TV/Stores/TVPlexAccountJWTStorage.swift
new file mode 100644
index 0000000..d24b90e
--- /dev/null
+++ b/PlexBar/TV/Stores/TVPlexAccountJWTStorage.swift
@@ -0,0 +1,57 @@
+import PlexModels
+import Foundation
+
+@MainActor
+final class TVPlexAccountJWTStorage: PlexAccountJWTStorage {
+ private enum DefaultsKey {
+ static let clientIdentifier = "tv.plex.clientIdentifier"
+ static let registeredJWTKeyID = "tv.plex.registeredJWTKeyID"
+ }
+
+ private let defaults: UserDefaults
+ private let keychain: KeychainStore
+
+ let clientIdentifier: String
+ private(set) var storedAccountToken = ""
+ private(set) var registeredJWTKeyID: String?
+
+ init(defaults: UserDefaults, keychain: KeychainStore) {
+ self.defaults = defaults
+ self.keychain = keychain
+
+ if let identifier = defaults.string(forKey: DefaultsKey.clientIdentifier)?.nilIfBlank {
+ clientIdentifier = identifier
+ } else {
+ let identifier = UUID().uuidString.lowercased()
+ defaults.set(identifier, forKey: DefaultsKey.clientIdentifier)
+ clientIdentifier = identifier
+ }
+
+ registeredJWTKeyID = defaults.string(
+ forKey: DefaultsKey.registeredJWTKeyID
+ )?.nilIfBlank
+ }
+
+ func loadAccountToken() async throws {
+ storedAccountToken = try await keychain.read(account: KeychainAccounts.userToken) ?? ""
+ }
+
+ func persistAccountToken(_ token: String) async throws {
+ let normalizedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
+ if normalizedToken.isEmpty {
+ try await keychain.delete(account: KeychainAccounts.userToken)
+ } else {
+ try await keychain.write(normalizedToken, account: KeychainAccounts.userToken)
+ }
+ storedAccountToken = normalizedToken
+ }
+
+ func markJWTKeyRegistered(keyID: String) {
+ guard let keyID = keyID.nilIfBlank,
+ registeredJWTKeyID != keyID else {
+ return
+ }
+ registeredJWTKeyID = keyID
+ defaults.set(keyID, forKey: DefaultsKey.registeredJWTKeyID)
+ }
+}
diff --git a/PlexBar/TV/Support/TVPlexMediaPresentation.swift b/PlexBar/TV/Support/TVPlexMediaPresentation.swift
new file mode 100644
index 0000000..9bdda08
--- /dev/null
+++ b/PlexBar/TV/Support/TVPlexMediaPresentation.swift
@@ -0,0 +1,53 @@
+import PlexModels
+import Foundation
+
+extension PlexMediaItem {
+ var tvCanStartPlayback: Bool {
+ if isPlayable || supportsHierarchyPlayback { return true }
+ // Hub and search responses may omit Media; preparation loads full metadata.
+ switch type?.lowercased() {
+ case "movie", "episode", "clip", "track": return true
+ default: return false
+ }
+ }
+
+ var displayTitle: String { title }
+
+ var contextTitle: String? {
+ switch type?.lowercased() {
+ case "episode": grandparentTitle
+ case "track": parentTitle ?? grandparentTitle
+ default: nil
+ }
+ }
+
+ var resumeSeconds: TimeInterval {
+ TimeInterval(viewOffset ?? 0) / 1_000
+ }
+
+ var durationSeconds: TimeInterval {
+ TimeInterval(duration ?? 0) / 1_000
+ }
+
+ var preferredLandscapePath: String? {
+ type?.lowercased() == "episode" ? thumb ?? art : preferredBackdropPath
+ }
+
+ var preferredBackdropPath: String? {
+ art ?? thumb ?? grandparentThumb ?? parentThumb ?? composite
+ }
+
+ var tvEpisodeTitle: String? {
+ guard type?.lowercased() == "episode" else { return nil }
+ return PlexMediaSummaryPresentation(item: self).episodeHeading
+ }
+
+ var tvResumeTitle: String? {
+ guard resumeSeconds > 0 else { return nil }
+ return "Resume " + Duration.seconds(resumeSeconds).formatted(.time(pattern: .hourMinuteSecond))
+ }
+
+ var metadataLine: String {
+ factsLine ?? ""
+ }
+}
diff --git a/PlexBar/TV/Views/TVCinematicMediaHero.swift b/PlexBar/TV/Views/TVCinematicMediaHero.swift
new file mode 100644
index 0000000..0509457
--- /dev/null
+++ b/PlexBar/TV/Views/TVCinematicMediaHero.swift
@@ -0,0 +1,86 @@
+import PlexModels
+import SwiftUI
+
+/// A single reading column leaves the right side of the artwork unobstructed.
+struct TVCinematicMediaHero: View {
+ let item: PlexMediaItem
+ let showSummary: () -> Void
+ @ViewBuilder let actions: Actions
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ identity
+
+ if let episodeTitle = item.tvEpisodeTitle {
+ Text(episodeTitle)
+ .font(TVTypography.sectionTitle)
+ .lineLimit(2)
+ }
+
+ metadata
+ PlexExternalRatingsView(item: item, valueFont: TVTypography.metadata)
+
+ if let summary = item.summary?.nilIfBlank {
+ Button(action: showSummary) {
+ Text(summary)
+ .font(TVTypography.body)
+ .lineLimit(3)
+ .multilineTextAlignment(.leading)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(12)
+ }
+ .buttonStyle(.plain)
+ .buttonBorderShape(.roundedRectangle(radius: 12))
+ .padding(.horizontal, -12)
+ .padding(.top, 8)
+ .accessibilityIdentifier("detail-summary")
+ .accessibilityHint("Select to read the full synopsis.")
+ }
+
+ actions
+ .padding(.top, 8)
+ }
+ .frame(width: 680, alignment: .leading)
+ .padding(.top, 40)
+ .padding(.bottom, 32)
+ .frame(maxWidth: .infinity, alignment: .topLeading)
+ .safeAreaPadding(.horizontal)
+ .focusSection()
+ }
+
+ private var identity: some View {
+ TVArtworkImage(path: item.clearLogoPath, width: 880, height: 280, usesOriginalImage: true) { image in
+ ZStack(alignment: .leading) {
+ if let image {
+ image.resizable().scaledToFit()
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
+ } else {
+ Text(item.contextTitle ?? item.title)
+ .font(TVTypography.title)
+ .lineLimit(2)
+ .minimumScaleFactor(0.7)
+ }
+ }
+ .frame(width: 320, height: 90, alignment: .leading)
+ }
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(item.contextTitle ?? item.title)
+ .accessibilityAddTraits(.isHeader)
+ }
+
+ @ViewBuilder
+ private var metadata: some View {
+ if let facts = PlexMediaSummaryPresentation(item: item).detailFacts {
+ Text(facts)
+ .font(TVTypography.metadata)
+ .foregroundStyle(.white.opacity(0.84))
+ .lineLimit(2)
+ }
+ if let genres = PlexMediaSummaryPresentation(item: item).genres {
+ Text(genres)
+ .font(TVTypography.metadata)
+ .foregroundStyle(.white.opacity(0.84))
+ .lineLimit(2)
+ }
+ }
+}
diff --git a/PlexBar/TV/Views/TVConnectionView.swift b/PlexBar/TV/Views/TVConnectionView.swift
new file mode 100644
index 0000000..528f77c
--- /dev/null
+++ b/PlexBar/TV/Views/TVConnectionView.swift
@@ -0,0 +1,160 @@
+import PlexModels
+import CoreImage
+import CoreImage.CIFilterBuiltins
+import SwiftUI
+
+struct TVConnectionView: View {
+ @Environment(TVAppStore.self) private var store
+
+ var body: some View {
+ Group {
+ if !store.availableServers.isEmpty {
+ TVServerSelectionView(servers: store.availableServers)
+ } else if store.connectionState == .connecting {
+ TVConnectingView()
+ } else if store.hasAuthorizedAccount, !store.isPairing {
+ TVReconnectView()
+ } else {
+ TVDeviceAuthorizationView(code: store.signInCode)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(TVCanvasBackground())
+ }
+}
+
+private struct TVDeviceAuthorizationView: View {
+ @Environment(TVAppStore.self) private var store
+ let code: String?
+
+ var body: some View {
+ VStack(spacing: 28) {
+ TVBrandMark(size: 64)
+
+ Text("Sign In to Plex")
+ .font(.title)
+
+ if let code, let linkURL = PlexRemoteService.linkURL(pinCode: code) {
+ TVDeviceAuthorizationQRCode(url: linkURL)
+
+ Text("Scan the code, or visit plex.tv/link and enter")
+ .font(.body)
+ .foregroundStyle(.secondary)
+
+ Text(code)
+ .font(.system(.title, design: .monospaced, weight: .bold))
+ .tracking(12)
+ .accessibilityLabel("Plex link code \(code)")
+ } else if store.isPairing {
+ ProgressView("Requesting a secure code…")
+ .controlSize(.large)
+ } else {
+ Button("Get a New Code", systemImage: "arrow.clockwise") {
+ store.startPlexDeviceAuthorization()
+ }
+ }
+
+ Text("After you approve this Apple TV, PlexBar discovers your servers automatically.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ .multilineTextAlignment(.center)
+ .safeAreaPadding()
+ }
+}
+
+private struct TVDeviceAuthorizationQRCode: View {
+ private let image: CGImage?
+
+ init(url: URL) {
+ let filter = CIFilter.qrCodeGenerator()
+ filter.message = Data(url.absoluteString.utf8)
+ filter.correctionLevel = "M"
+
+ guard let outputImage = filter.outputImage else {
+ image = nil
+ return
+ }
+ image = CIContext().createCGImage(outputImage, from: outputImage.extent)
+ }
+
+ var body: some View {
+ Group {
+ if let image {
+ Image(decorative: image, scale: 1)
+ .resizable()
+ .interpolation(.none)
+ .scaledToFit()
+ } else {
+ Image(systemName: "qrcode")
+ .resizable()
+ .scaledToFit()
+ .foregroundStyle(.black)
+ }
+ }
+ .padding(16)
+ .frame(width: 220, height: 220)
+ .background(.white, in: .rect(cornerRadius: 12))
+ .accessibilityElement()
+ .accessibilityLabel("QR code to sign in to Plex")
+ .accessibilityHint("Scan this code with a phone camera")
+ }
+}
+
+private struct TVConnectingView: View {
+ var body: some View {
+ TVLoadingView(title: "Connecting to Plex…")
+ }
+}
+
+private struct TVReconnectView: View {
+ @Environment(TVAppStore.self) private var store
+
+ var body: some View {
+ ContentUnavailableView {
+ Label("Can’t Reach Your Plex Server", systemImage: "wifi.exclamationmark")
+ } description: {
+ Text("Your account is still linked. PlexBar can discover the server again.")
+ } actions: {
+ Button("Try Again", systemImage: "arrow.clockwise") {
+ Task { await store.reconnectAuthorizedAccount() }
+ }
+ }
+ }
+}
+
+private struct TVServerSelectionView: View {
+ @Environment(TVAppStore.self) private var store
+ let servers: [PlexServerResource]
+
+ var body: some View {
+ NavigationStack {
+ List {
+ Section {
+ ForEach(servers) { server in
+ Button {
+ select(server)
+ } label: {
+ Label {
+ Text(server.name)
+ } icon: {
+ Image(systemName: server.preferredConnection?.local == true
+ ? "house.fill"
+ : "network")
+ }
+ }
+ }
+ } header: {
+ Text("Available Servers")
+ } footer: {
+ Text("PlexBar remembers your selection on this Apple TV.")
+ }
+ }
+ .navigationTitle("Choose a Plex Server")
+ }
+ }
+
+ private func select(_ server: PlexServerResource) {
+ Task { await store.selectServer(server) }
+ }
+}
diff --git a/PlexBar/TV/Views/TVDesignSystem.swift b/PlexBar/TV/Views/TVDesignSystem.swift
new file mode 100644
index 0000000..41b7e5c
--- /dev/null
+++ b/PlexBar/TV/Views/TVDesignSystem.swift
@@ -0,0 +1,469 @@
+import PlexModels
+import SwiftUI
+
+enum TVTheme {
+ static let plexGold = Color(red: 0.90, green: 0.63, blue: 0.05)
+ static let canvas = Color(red: 0.025, green: 0.028, blue: 0.035)
+}
+
+enum TVTypography {
+ static let title = Font.system(size: 36, weight: .semibold)
+ static let sectionTitle = Font.system(size: 28, weight: .semibold)
+ static let body = Font.system(size: 24)
+ static let action = Font.system(size: 24, weight: .semibold)
+ static let cardTitle = Font.system(size: 20, weight: .semibold)
+ static let metadata = Font.system(size: 20)
+ static let caption = Font.system(size: 18)
+}
+
+enum TVLayout {
+ static let sectionSpacing: CGFloat = 32
+ static let cardSpacing: CGFloat = 24
+ static let posterColumns = 7
+ static let episodeColumns = 6
+ static let castColumns = 11
+ static let posterGridColumns = Array(
+ repeating: GridItem(.flexible(), spacing: cardSpacing, alignment: .top),
+ count: posterColumns
+ )
+}
+
+extension PlexMediaArtworkShape {
+ var shelfColumnCount: Int {
+ switch self {
+ case .poster: TVLayout.posterColumns
+ case .landscape: TVLayout.episodeColumns
+ case .square: TVLayout.posterColumns
+ }
+ }
+}
+
+enum TVLockupSizing: Sendable {
+ case shelf
+ case grid
+}
+
+struct TVCanvasBackground: View {
+ var body: some View {
+ TVTheme.canvas
+ .ignoresSafeArea()
+ }
+}
+
+struct TVBrandMark: View {
+ var size: CGFloat = 88
+
+ var body: some View {
+ Image("ribbon-balloon")
+ .resizable()
+ .scaledToFit()
+ .frame(width: size, height: size)
+ .accessibilityLabel("PlexBar")
+ }
+}
+
+/// Resolve one request before laying out its image. The request identity includes
+/// the server and dimensions, so reused rows never retain another request's art.
+struct TVArtworkImage: View {
+ @Environment(TVAppStore.self) private var store
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+ let path: String?
+ let width: Int
+ let height: Int
+ var usesOriginalImage = false
+ @ViewBuilder let content: (Image?) -> Content
+ @State private var url: URL?
+
+ var body: some View {
+ AsyncImage(url: url, transaction: Transaction(
+ animation: PlexMotion.contentReplacementAnimation(reduceMotion: reduceMotion)
+ )) { phase in
+ content(phase.image)
+ }
+ .task(id: requestID) {
+ url = nil
+ let resolvedURL = await store.artworkURL(
+ path: path, width: width, height: height, usesOriginalImage: usesOriginalImage
+ )
+ guard !Task.isCancelled else { return }
+ url = resolvedURL
+ }
+ }
+
+ private var requestID: String {
+ [path ?? "", String(width), String(height), String(usesOriginalImage),
+ store.connection?.serverURL.absoluteString ?? "",
+ store.connection?.token ?? ""].joined(separator: "|")
+ }
+}
+
+struct TVPlexArtwork: View {
+ let path: String?
+ let width: Int
+ let height: Int
+ let systemImage: String
+ var usesOriginalImage = false
+ var contentMode: ContentMode = .fill
+ var showsPlaceholder = true
+
+ var body: some View {
+ // A flexible base owns the size, including while loading or missing art.
+ // The decoded image can never expand a shelf or change its aspect ratio.
+ Rectangle()
+ .fill(showsPlaceholder ? Color.white.opacity(0.06) : .clear)
+ .overlay {
+ TVArtworkImage(path: path, width: width, height: height, usesOriginalImage: usesOriginalImage) { image in
+ GeometryReader { geometry in
+ if let image {
+ image.resizable()
+ .aspectRatio(contentMode: contentMode)
+ .frame(width: geometry.size.width, height: geometry.size.height)
+ } else if showsPlaceholder {
+ Image(systemName: systemImage)
+ .font(.largeTitle.weight(.ultraLight))
+ .foregroundStyle(.tertiary)
+ .frame(width: geometry.size.width, height: geometry.size.height)
+ }
+ }
+ }
+ }
+ .clipped()
+ .accessibilityHidden(true)
+ }
+}
+
+struct TVProgressBar: View {
+ let progress: Double
+
+ var body: some View {
+ GeometryReader { geometry in
+ Capsule()
+ .fill(.white.opacity(0.28))
+ .overlay(alignment: .leading) {
+ Capsule()
+ .fill(.white)
+ .frame(width: geometry.size.width * min(max(progress, 0), 1))
+ }
+ }
+ .frame(height: 4)
+ .accessibilityLabel("Playback progress")
+ .accessibilityValue(progress.formatted(.percent.precision(.fractionLength(0))))
+ }
+}
+
+struct TVPlaybackButton: View {
+ @Environment(TVAppStore.self) private var store
+
+ let item: PlexMediaItem
+ let title: String?
+ let systemImage: String
+ let preparationKind: TVPlaybackPreparationKind
+ let fillsAvailableWidth: Bool
+ let isIconOnly: Bool
+ let action: () -> Void
+
+ init(
+ item: PlexMediaItem,
+ title: String? = nil,
+ systemImage: String = "play.fill",
+ preparationKind: TVPlaybackPreparationKind = .content,
+ fillsAvailableWidth: Bool = true,
+ isIconOnly: Bool = false,
+ action: @escaping () -> Void
+ ) {
+ self.item = item
+ self.title = title
+ self.systemImage = systemImage
+ self.preparationKind = preparationKind
+ self.fillsAvailableWidth = fillsAvailableWidth
+ self.isIconOnly = isIconOnly
+ self.action = action
+ }
+
+ @ViewBuilder
+ var body: some View {
+ Button(action: action) {
+ buttonLabel
+ .frame(maxWidth: fillsAvailableWidth ? .infinity : nil)
+ }
+ .buttonStyle(.bordered)
+ .disabled(isPreparing)
+ .accessibilityLabel(isPreparing ? preparingAccessibilityLabel : playbackAccessibilityLabel)
+ }
+
+ private var buttonLabel: some View {
+ Group {
+ if isIconOnly {
+ Image(systemName: isPreparing ? "progress.indicator" : systemImage)
+ } else {
+ Label(isPreparing ? "Preparing…" : actionTitle,
+ systemImage: isPreparing ? "progress.indicator" : systemImage)
+ }
+ }
+ .font(TVTypography.action)
+ }
+
+ private var isPreparing: Bool {
+ store.isPreparingPlayback(item, kind: preparationKind)
+ }
+
+ private var actionTitle: String {
+ title ?? (item.resumeSeconds > 0 ? "Resume" : "Play")
+ }
+
+ private var preparingAccessibilityLabel: String {
+ if let title {
+ return "Preparing \(title) for \(item.title)"
+ }
+ return "Preparing \(item.title)"
+ }
+
+ private var playbackAccessibilityLabel: String {
+ if let title {
+ return "\(title) for \(item.title)"
+ }
+ return item.resumeSeconds > 0
+ ? "Resume \(item.title)"
+ : "Play \(item.title)"
+ }
+}
+
+struct TVMediaLockup: View {
+ @Environment(TVAppStore.self) private var store
+
+ let item: PlexMediaItem
+ let artworkStyle: PlexMediaArtworkShape
+ let artworkLayout: PlexMediaArtworkLayout
+ let sizing: TVLockupSizing
+ let columnCount: Int?
+ let showsEpisodeNumber: Bool
+ let selectionAction: (() -> Void)?
+ let isSelected: Bool
+ @FocusState private var isFocused: Bool
+
+ init(
+ item: PlexMediaItem,
+ artworkStyle: PlexMediaArtworkShape,
+ artworkLayout: PlexMediaArtworkLayout = .automatic,
+ sizing: TVLockupSizing = .shelf,
+ columnCount: Int? = nil,
+ showsEpisodeNumber: Bool = false,
+ selectionAction: (() -> Void)? = nil,
+ isSelected: Bool = false
+ ) {
+ self.item = item
+ self.artworkStyle = artworkStyle
+ self.artworkLayout = artworkLayout
+ self.sizing = sizing
+ self.columnCount = columnCount
+ self.showsEpisodeNumber = showsEpisodeNumber
+ self.selectionAction = selectionAction
+ self.isSelected = isSelected
+ }
+
+ var body: some View {
+ Group {
+ if let selectionAction {
+ Button(action: selectionAction) { card }
+ } else if item.type?.lowercased() == "clip" {
+ Button { store.play(item) } label: { card }
+ } else {
+ NavigationLink(value: TVNavigationRoute.media(item)) { card }
+ }
+ }
+ .buttonStyle(.borderless)
+ .modifier(TVLockupFrame(style: artworkStyle, sizing: sizing, columnCount: columnCount))
+ .focused($isFocused)
+ .accessibilityIdentifier("media.\(item.type ?? "unknown").\(item.ratingKey)")
+ .onPlayPauseCommand {
+ if isFocused, item.tvCanStartPlayback { store.play(item) }
+ }
+ .accessibilityLabel([title, subtitle].compactMap { $0 }.joined(separator: ", "))
+ .accessibilityHint(item.type?.lowercased() == "clip"
+ ? "Select to play."
+ : item.tvCanStartPlayback ? "Press Play/Pause to play. Select for details." : "Select for details.")
+ .contextMenu {
+ if item.tvCanStartPlayback {
+ Button(item.resumeSeconds > 0 ? "Resume" : "Play", systemImage: "play.fill") {
+ store.play(item)
+ }
+ if item.isPlayable, item.resumeSeconds > 0 {
+ Button("Play from Beginning", systemImage: "backward.end.fill") {
+ store.play(item, resume: false)
+ }
+ }
+ }
+ }
+ }
+
+ private var card: some View {
+ VStack(alignment: .leading, spacing: 18) {
+ artwork
+ TVMediaCardLabels(
+ title: title,
+ subtitle: subtitle,
+ titleLineLimit: showsEpisodeNumber ? 1 : 2
+ )
+ }
+ }
+
+ private var title: String {
+ guard showsEpisodeNumber, let index = item.index else { return item.displayTitle }
+ return "\(index). \(item.displayTitle)"
+ }
+
+ private var subtitle: String? {
+ guard showsEpisodeNumber else { return item.subtitle }
+ return item.formattedDuration
+ }
+
+ @ViewBuilder
+ private var artwork: some View {
+ let image = TVPlexArtwork(
+ path: artworkPath,
+ width: artworkStyle == .landscape ? 960 : 600,
+ height: artworkStyle == .landscape ? 540 : (artworkStyle == .square ? 600 : 900),
+ systemImage: item.type?.lowercased() == "track" ? "music.note" : "film"
+ )
+ .aspectRatio(artworkStyle.aspectRatio, contentMode: .fit)
+ .overlay(alignment: .bottom) {
+ if let progress = item.progress, progress > 0, progress < 0.98 {
+ TVProgressBar(progress: progress)
+ .padding(10)
+ }
+ }
+ .clipShape(.rect(cornerRadius: 12))
+ .plexWatchedIndicator(isWatched: item.isWatched, scale: .large)
+ .overlay {
+ RoundedRectangle(cornerRadius: 12)
+ .stroke(isSelected ? TVTheme.plexGold : .clear, lineWidth: 3)
+ }
+ .hoverEffect(.highlight)
+
+ image
+ }
+
+ private var artworkPath: String? {
+ PlexMediaArtworkPresentation(item: item, layout: artworkLayout).path
+ }
+}
+
+private struct TVLockupFrame: ViewModifier {
+ let style: PlexMediaArtworkShape
+ let sizing: TVLockupSizing
+ let columnCount: Int?
+
+ @ViewBuilder
+ func body(content: Content) -> some View {
+ switch sizing {
+ case .shelf:
+ content.containerRelativeFrame(.horizontal, count: columnCount ?? style.shelfColumnCount, spacing: TVLayout.cardSpacing)
+ case .grid:
+ content.frame(maxWidth: .infinity)
+ }
+ }
+}
+
+struct TVPersonLockup: View {
+ let credit: PlexCastAndCrewCredit
+
+ @ViewBuilder
+ var body: some View {
+ if let route = credit.route {
+ NavigationLink(value: TVNavigationRoute.person(route)) {
+ label
+ }
+ .buttonStyle(.borderless)
+ .containerRelativeFrame(.horizontal, count: TVLayout.castColumns, spacing: TVLayout.cardSpacing)
+ .accessibilityLabel(accessibilityLabel)
+ .accessibilityHint("Opens this person's Plex appearances.")
+ } else {
+ label
+ .containerRelativeFrame(.horizontal, count: TVLayout.castColumns, spacing: TVLayout.cardSpacing)
+ .accessibilityElement(children: .combine)
+ }
+ }
+
+ private var label: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ portrait
+ TVMediaCardLabels(title: credit.name, subtitle: credit.subtitle)
+ }
+ }
+
+ private var portrait: some View {
+ TVPlexArtwork(
+ path: credit.thumb,
+ width: 320,
+ height: 320,
+ systemImage: "person.fill"
+ )
+ .aspectRatio(1, contentMode: .fit)
+ .containerRelativeFrame(.horizontal, count: TVLayout.castColumns, spacing: TVLayout.cardSpacing)
+ .clipShape(.rect(cornerRadius: 12))
+ .hoverEffect(.highlight)
+ }
+
+ private var accessibilityLabel: String {
+ "\(credit.name), \(credit.subtitle)"
+ }
+}
+
+struct TVMediaCardLabels: View {
+ let title: String
+ let subtitle: String?
+ var titleLineLimit = 2
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(title)
+ .font(TVTypography.cardTitle)
+ .lineLimit(titleLineLimit, reservesSpace: titleLineLimit > 1)
+
+ if let subtitle {
+ Text(subtitle)
+ .font(TVTypography.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+ .multilineTextAlignment(.leading)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
+
+struct TVMediaGrid: View {
+ let items: [PlexMediaItem]
+ var artworkLayout: PlexMediaArtworkLayout = .automatic
+ var onItemAppear: ((PlexMediaItem) async -> Void)?
+
+ var body: some View {
+ LazyVGrid(columns: TVLayout.posterGridColumns, alignment: .leading, spacing: 28) {
+ ForEach(items) { item in
+ TVMediaLockup(
+ item: item,
+ artworkStyle: PlexMediaArtworkPresentation(item: item, layout: artworkLayout).shape,
+ artworkLayout: artworkLayout,
+ sizing: .grid
+ )
+ .task { await onItemAppear?(item) }
+ }
+ }
+ }
+}
+
+struct TVLoadingView: View {
+ let title: String
+
+ var body: some View {
+ VStack(spacing: 24) {
+ ProgressView()
+ .controlSize(.large)
+ Text(title)
+ .font(.headline)
+ .foregroundStyle(.secondary)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .accessibilityElement(children: .combine)
+ }
+}
diff --git a/PlexBar/TV/Views/TVDetailDiscoveryView.swift b/PlexBar/TV/Views/TVDetailDiscoveryView.swift
new file mode 100644
index 0000000..d73cd27
--- /dev/null
+++ b/PlexBar/TV/Views/TVDetailDiscoveryView.swift
@@ -0,0 +1,95 @@
+import PlexModels
+import SwiftUI
+
+/// Keeps the macOS detail order: credits, details, extras, then Plex's related hubs.
+struct TVDetailDiscoveryView: View {
+ @Environment(TVAppStore.self) private var store
+ let item: PlexMediaItem
+
+ @State private var extras: [PlexMediaItem] = []
+ @State private var relatedHubs: [PlexHub] = []
+ @State private var extrasLoading = true
+ @State private var relatedLoading = true
+ @State private var extrasError: String?
+ @State private var relatedError: String?
+ @State private var extrasReloadID = UUID()
+ @State private var relatedReloadID = UUID()
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: TVLayout.sectionSpacing) {
+ if item.supportsMediaExtras, extrasLoading, extras.isEmpty {
+ ProgressView("Loading Extras")
+ .safeAreaPadding(.horizontal)
+ }
+ if let extrasError {
+ loadError(title: "Couldn’t Load Extras", message: extrasError) {
+ extrasReloadID = UUID()
+ }
+ }
+ if !extras.isEmpty {
+ TVMediaShelf(title: "Extras", items: extras)
+ }
+
+ if relatedLoading, relatedHubs.isEmpty {
+ ProgressView("Loading Related Content")
+ .safeAreaPadding(.horizontal)
+ }
+ if let relatedError {
+ loadError(title: "Couldn’t Load Related Content", message: relatedError) {
+ relatedReloadID = UUID()
+ }
+ }
+ ForEach(relatedHubs) { hub in
+ TVMediaShelf(hub: hub)
+ }
+ }
+ .task(id: loadIdentity(reloadID: extrasReloadID)) {
+ extrasLoading = true
+ extrasError = nil
+ do {
+ let result = try await store.mediaExtras(for: item)
+ guard !Task.isCancelled else { return }
+ extras = result
+ } catch {
+ guard !Task.isCancelled else { return }
+ extrasError = error.localizedDescription
+ }
+ extrasLoading = false
+ }
+ .task(id: loadIdentity(reloadID: relatedReloadID)) {
+ relatedLoading = true
+ relatedError = nil
+ do {
+ let result = try await store.relatedHubs(for: item)
+ guard !Task.isCancelled else { return }
+ relatedHubs = result
+ } catch {
+ guard !Task.isCancelled else { return }
+ relatedError = error.localizedDescription
+ }
+ relatedLoading = false
+ }
+ }
+
+ private func loadIdentity(reloadID: UUID) -> LoadIdentity {
+ LoadIdentity(ratingKey: item.ratingKey, connection: store.connection,
+ revision: store.playbackMetadataRevision, reloadID: reloadID)
+ }
+
+ private func loadError(title: String, message: String, retry: @escaping () -> Void) -> some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Text(title).font(TVTypography.sectionTitle)
+ Text(message).font(TVTypography.metadata).foregroundStyle(.secondary)
+ Button("Try Again", systemImage: "arrow.clockwise", action: retry)
+ .font(TVTypography.action)
+ }
+ .safeAreaPadding(.horizontal)
+ }
+
+ private struct LoadIdentity: Equatable {
+ let ratingKey: String
+ let connection: TVPlexConnection?
+ let revision: UUID
+ let reloadID: UUID
+ }
+}
diff --git a/PlexBar/TV/Views/TVHomeView.swift b/PlexBar/TV/Views/TVHomeView.swift
new file mode 100644
index 0000000..47b5bf5
--- /dev/null
+++ b/PlexBar/TV/Views/TVHomeView.swift
@@ -0,0 +1,158 @@
+import PlexModels
+import SwiftUI
+
+struct TVHomeView: View {
+ @Environment(TVAppStore.self) private var store
+
+ var body: some View {
+ @Bindable var store = store
+
+ NavigationStack(path: $store.homePath) {
+ Group {
+ if store.isLoadingHome, store.homeHubs.isEmpty {
+ TVLoadingView(title: "Loading Home…")
+ } else if store.homeHubs.isEmpty {
+ TVHomeEmptyView(refresh: refresh)
+ } else {
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: TVLayout.sectionSpacing) {
+ ForEach(store.homeHubs) { hub in
+ TVMediaShelf(hub: hub, videoOnly: true)
+ }
+ }
+ .safeAreaPadding(.vertical)
+ }
+ .scrollClipDisabled()
+ }
+ }
+ .background(TVCanvasBackground())
+ .navigationDestination(for: TVNavigationRoute.self) { route in
+ TVNavigationDestination(route: route)
+ }
+ }
+ }
+
+ private func refresh() {
+ Task { await store.refreshAll() }
+ }
+}
+
+struct TVMediaShelf: View {
+ @Environment(TVAppStore.self) private var store
+ @State private var browser: TVHubBrowseStore
+
+ enum ArtworkPreference {
+ case automatic
+ case poster
+ case landscape
+ }
+
+ let title: String
+ let items: [PlexMediaItem]
+ let artworkPreference: ArtworkPreference
+ let columnCount: Int?
+ private var hub: PlexHub?
+ var showsEpisodeNumbers = false
+ var selectedItemID: String?
+ var selectItem: ((PlexMediaItem) -> Void)?
+
+ init(hub: PlexHub, videoOnly: Bool = false) {
+ _browser = State(initialValue: TVHubBrowseStore(videoOnly: videoOnly))
+ self.hub = hub
+ title = hub.title
+ items = hub.metadata
+ artworkPreference = hub.prefersPosterArtwork ? .poster : .automatic
+ columnCount = nil
+ }
+
+ init(
+ title: String,
+ items: [PlexMediaItem],
+ artworkPreference: ArtworkPreference = .automatic,
+ columnCount: Int? = nil,
+ showsEpisodeNumbers: Bool = false,
+ selectedItemID: String? = nil,
+ selectItem: ((PlexMediaItem) -> Void)? = nil
+ ) {
+ _browser = State(initialValue: TVHubBrowseStore())
+ self.title = title
+ self.items = items
+ self.artworkPreference = artworkPreference
+ self.columnCount = columnCount
+ self.showsEpisodeNumbers = showsEpisodeNumbers
+ self.selectedItemID = selectedItemID
+ self.selectItem = selectItem
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ Section {
+ ScrollView(.horizontal) {
+ LazyHStack(alignment: .top, spacing: TVLayout.cardSpacing) {
+ ForEach(visibleItems) { item in
+ TVMediaLockup(
+ item: item,
+ artworkStyle: artworkStyle(for: item),
+ artworkLayout: artworkPreference == .poster ? .poster : .automatic,
+ columnCount: columnCount,
+ showsEpisodeNumber: showsEpisodeNumbers,
+ selectionAction: selectItem.map { action in { action(item) } },
+ isSelected: item.id == selectedItemID
+ )
+ .task(id: hub.map { TVHubBrowseStore.Identity(hub: $0, connection: store.connection) }) {
+ if let hub { await browser.loadInline(hub: hub, using: store, after: item) }
+ }
+ }
+ if let hub {
+ TVHubLoadingStatus(hub: hub, browser: browser)
+ }
+ }
+ .safeAreaPadding(.horizontal)
+ }
+ .scrollClipDisabled()
+ .buttonStyle(.borderless)
+ } header: {
+ if !title.isEmpty {
+ Text(title)
+ .font(TVTypography.sectionTitle)
+ .safeAreaPadding(.horizontal)
+ .accessibilityAddTraits(.isHeader)
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .accessibilityElement(children: .contain)
+ .accessibilityLabel(title)
+ .focusSection()
+ }
+
+ private var visibleItems: [PlexMediaItem] {
+ guard let hub else { return items }
+ return browser.visibleItems(hub: hub, connection: store.connection)
+ }
+
+ private func artworkStyle(for item: PlexMediaItem) -> PlexMediaArtworkShape {
+ switch artworkPreference {
+ case .automatic:
+ return .automatic(for: item)
+ case .poster:
+ return .poster
+ case .landscape:
+ return .landscape
+ }
+ }
+}
+
+private struct TVHomeEmptyView: View {
+ let refresh: () -> Void
+
+ var body: some View {
+ ContentUnavailableView {
+ Label("No Home Content", systemImage: "rectangle.stack.badge.minus")
+ } description: {
+ Text("Plex did not return any promoted media from this server.")
+ } actions: {
+ Button("Refresh", systemImage: "arrow.clockwise", action: refresh)
+ }
+ }
+}
diff --git a/PlexBar/TV/Views/TVHubLoadingStatus.swift b/PlexBar/TV/Views/TVHubLoadingStatus.swift
new file mode 100644
index 0000000..2b7b49f
--- /dev/null
+++ b/PlexBar/TV/Views/TVHubLoadingStatus.swift
@@ -0,0 +1,30 @@
+import SwiftUI
+
+/// Only failures add an action at the end of the content. Normal pagination
+/// requires no extra remote press and adds no focusable heading or footer.
+struct TVHubLoadingStatus: View {
+ @Environment(TVAppStore.self) private var store
+ let hub: PlexHub
+ let browser: TVHubBrowseStore
+
+ var body: some View {
+ if let error = browser.errorMessage {
+ VStack(alignment: .leading, spacing: 12) {
+ Text(error)
+ .font(TVTypography.metadata)
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: 320, alignment: .leading)
+ Button("Try Again", systemImage: "arrow.clockwise") {
+ Task {
+ await browser.retry(hub: hub, using: store)
+ if let last = browser.items.last {
+ await browser.loadInline(hub: hub, using: store, after: last)
+ }
+ }
+ }
+ }
+ } else if browser.isLoading {
+ ProgressView("Loading…")
+ }
+ }
+}
diff --git a/PlexBar/TV/Views/TVLibrariesView.swift b/PlexBar/TV/Views/TVLibrariesView.swift
new file mode 100644
index 0000000..135d918
--- /dev/null
+++ b/PlexBar/TV/Views/TVLibrariesView.swift
@@ -0,0 +1,77 @@
+import SwiftUI
+
+struct TVLibrariesView: View {
+ @Environment(TVAppStore.self) private var store
+
+ private let columns = Array(
+ repeating: GridItem(.flexible(), spacing: TVLayout.cardSpacing, alignment: .top),
+ count: 5
+ )
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if store.isLoadingLibraries, store.libraries.isEmpty {
+ TVLoadingView(title: "Loading Libraries…")
+ } else if store.libraries.isEmpty {
+ ContentUnavailableView(
+ "No Libraries",
+ systemImage: "rectangle.stack.badge.minus",
+ description: Text("No libraries are available on this server.")
+ )
+ } else {
+ ScrollView {
+ LazyVGrid(columns: columns, alignment: .leading, spacing: TVLayout.sectionSpacing) {
+ ForEach(store.libraries) { library in
+ NavigationLink(value: library) {
+ TVLibraryLockup(library: library)
+ }
+ }
+ }
+ .buttonStyle(.borderless)
+ .safeAreaPadding(.horizontal)
+ .safeAreaPadding(.vertical)
+ }
+ .scrollClipDisabled()
+ }
+ }
+ .background(TVCanvasBackground())
+ .navigationDestination(for: TVPlexLibrary.self) { library in
+ TVLibraryDetailView(library: library)
+ }
+ .navigationDestination(for: TVNavigationRoute.self) { route in
+ TVNavigationDestination(route: route)
+ }
+ }
+ }
+}
+
+private struct TVLibraryLockup: View {
+ let library: TVPlexLibrary
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 18) {
+ TVPlexArtwork(
+ path: library.artworkPath,
+ width: 960,
+ height: 540,
+ systemImage: icon,
+ usesOriginalImage: true
+ )
+ .aspectRatio(16.0 / 9.0, contentMode: .fit)
+ .clipShape(.rect(cornerRadius: 12))
+ .hoverEffect(.highlight)
+
+ TVMediaCardLabels(title: library.title, subtitle: library.type.capitalized)
+ }
+ }
+
+ private var icon: String {
+ switch library.type.lowercased() {
+ case "movie": "film.stack"
+ case "show": "tv"
+ case "artist": "music.note.list"
+ default: "rectangle.stack"
+ }
+ }
+}
diff --git a/PlexBar/TV/Views/TVLibraryDetailView.swift b/PlexBar/TV/Views/TVLibraryDetailView.swift
new file mode 100644
index 0000000..2489dd0
--- /dev/null
+++ b/PlexBar/TV/Views/TVLibraryDetailView.swift
@@ -0,0 +1,277 @@
+import PlexModels
+import SwiftUI
+
+struct TVLibraryDetailView: View {
+ @Environment(TVAppStore.self) private var store
+ let library: TVPlexLibrary
+ @State private var options = PlexLibraryBrowseOptions.default
+ @State private var definition: PlexLibraryBrowseDefinition?
+ @State private var definitionError: String?
+ @State private var definitionReloadID = UUID()
+ @State private var presentedFilter: PlexLibraryFilterDefinition?
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ HStack(spacing: 20) {
+ Text(library.title).font(TVTypography.title)
+ Spacer()
+ sortMenu
+ filterMenu
+ Button {
+ Task { await store.loadLibrary(library, options: options, refresh: true) }
+ } label: {
+ Image(systemName: "arrow.clockwise")
+ }
+ .accessibilityLabel("Refresh")
+ .disabled(store.isLoading(library))
+ }
+ .font(TVTypography.action)
+ .safeAreaPadding(.horizontal)
+ .padding(.top, 32)
+ .focusSection()
+ if let definitionError {
+ errorRow("Couldn’t Load Browse Options", message: definitionError) { definitionReloadID = UUID() }
+ }
+ if let error = store.libraryErrors[library.id] {
+ errorRow("Couldn’t Load \(library.title)", message: error) {
+ Task {
+ await store.retryLibrary(library, options: options)
+ }
+ }
+ }
+ if store.isLoading(library), items.isEmpty {
+ TVLoadingView(title: "Loading \(library.title)…")
+ } else if items.isEmpty, store.libraryErrors[library.id] == nil {
+ ContentUnavailableView {
+ Label(options.hasFilters ? "No Matching Items" : "Nothing Here", systemImage: "rectangle.stack.badge.minus")
+ } description: {
+ Text(options.hasFilters ? "No media matches the selected filters." : "This library does not contain any visible media.")
+ } actions: {
+ if options.hasFilters { Button("Clear Filters", action: clearFilters) }
+ }
+ } else {
+ TVMediaGrid(items: items) { item in
+ guard store.libraryErrors[library.id] == nil else { return }
+ await store.loadMoreLibraryItems(library, currentItem: item)
+ }
+ .safeAreaPadding(.horizontal)
+ .safeAreaPadding(.vertical)
+ if store.isLoading(library) { ProgressView("Loading more…") }
+ }
+ }
+ }
+ .scrollClipDisabled()
+ .background(TVCanvasBackground())
+ .ignoresSafeArea(.container, edges: .top)
+ .toolbarVisibility(.hidden, for: .navigationBar)
+ .toolbarVisibility(.hidden, for: .tabBar)
+ .task(id: BrowseIdentity(connection: store.connection, options: options)) {
+ await store.loadLibrary(library, options: options)
+ }
+ .task(id: DefinitionIdentity(connection: store.connection, reloadID: definitionReloadID)) {
+ definition = nil
+ definitionError = nil
+ do {
+ let result = try await store.libraryBrowseDefinition(library)
+ try Task.checkCancellation()
+ definition = result
+ } catch is CancellationError {
+ return
+ } catch {
+ guard !Task.isCancelled else { return }
+ definitionError = error.localizedDescription
+ }
+ }
+ .sheet(item: $presentedFilter) { filter in
+ TVLibraryFilterPicker(filter: filter, selectedValues: options.valueSelections(for: filter.id)) {
+ options.setValueSelections($0, for: filter.id)
+ }
+ }
+ }
+
+ private var items: [PlexMediaItem] { store.libraryItems[library.id] ?? [] }
+
+ private var sortMenu: some View {
+ Menu {
+ Button { options.sort = nil } label: { menuLabel("Default", selected: options.sort == nil) }
+ ForEach(definition?.sorts ?? []) { sort in
+ Button { options.sort = sort.selection() } label: {
+ menuLabel(sort.title, selected: options.sort?.sortID == sort.id)
+ }
+ }
+ if let sort = definition?.sorts.first(where: { $0.id == options.sort?.sortID }) {
+ Section("Direction") {
+ Button { options.sort = sort.selection(direction: .ascending) } label: {
+ menuLabel("Ascending", selected: options.sort?.direction == .ascending)
+ }
+ if sort.descendingKey != nil {
+ Button { options.sort = sort.selection(direction: .descending) } label: {
+ menuLabel("Descending", selected: options.sort?.direction == .descending)
+ }
+ }
+ }
+ }
+ } label: { Image(systemName: "arrow.up.arrow.down") }
+ .disabled(definition?.sorts.isEmpty != false)
+ .accessibilityIdentifier("library-sort")
+ .accessibilityLabel("Sort \(library.title)")
+ .accessibilityValue(options.sort.flatMap { selection in definition?.sorts.first { $0.id == selection.sortID }?.title } ?? "Default")
+ }
+
+ private var filterMenu: some View {
+ Menu {
+ Section("Status") {
+ ForEach(definition?.booleanFilters ?? []) { filter in
+ Toggle(filter.title, isOn: Binding(
+ get: { options.enabledBooleanFilterIDs.contains(filter.id) },
+ set: { enabled in
+ if enabled { options.enabledBooleanFilterIDs.insert(filter.id) }
+ else { options.enabledBooleanFilterIDs.remove(filter.id) }
+ }
+ ))
+ }
+ }
+ Section("Details") {
+ ForEach(definition?.valueFilters ?? []) { filter in
+ Button { presentedFilter = filter } label: {
+ let count = options.valueSelections(for: filter.id).count
+ menuLabel(count == 0 ? filter.title : "\(filter.title) (\(count))", selected: count > 0)
+ }
+ }
+ }
+ Button("Clear Filters", action: clearFilters).disabled(!options.hasFilters)
+ } label: {
+ Image(systemName: options.hasFilters ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease")
+ }
+ .disabled(definition?.filters.isEmpty != false)
+ .accessibilityIdentifier("library-filter")
+ .accessibilityLabel("Filter \(library.title)")
+ }
+
+ private func clearFilters() {
+ options.enabledBooleanFilterIDs.removeAll()
+ options.valueFilterSelections.removeAll()
+ }
+
+ @ViewBuilder private func menuLabel(_ title: String, selected: Bool) -> some View {
+ if selected { Label(title, systemImage: "checkmark") }
+ else { Text(title) }
+ }
+
+ private func errorRow(_ title: String, message: String, retry: @escaping () -> Void) -> some View {
+ HStack(spacing: 24) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(title).font(TVTypography.body)
+ Text(message).font(TVTypography.caption).foregroundStyle(.secondary)
+ }
+ Button("Try Again", action: retry).font(TVTypography.action)
+ }
+ .safeAreaPadding(.horizontal)
+ }
+
+ private struct BrowseIdentity: Equatable {
+ let connection: TVPlexConnection?
+ let options: PlexLibraryBrowseOptions
+ }
+ private struct DefinitionIdentity: Equatable {
+ let connection: TVPlexConnection?
+ let reloadID: UUID
+ }
+}
+
+private struct TVLibraryFilterPicker: View {
+ @Environment(TVAppStore.self) private var store
+ @Environment(\.dismiss) private var dismiss
+ let filter: PlexLibraryFilterDefinition
+ let apply: ([PlexLibraryFilterValue]) -> Void
+ @State private var selectedIDs: Set
+ @State private var values: [PlexLibraryFilterValue] = []
+ @State private var isLoaded = false
+ @State private var error: String?
+ @State private var searchText = ""
+ @State private var reloadID = UUID()
+
+ init(filter: PlexLibraryFilterDefinition, selectedValues: [PlexLibraryFilterValue], apply: @escaping ([PlexLibraryFilterValue]) -> Void) {
+ self.filter = filter
+ self.apply = apply
+ _selectedIDs = State(initialValue: Set(selectedValues.map(\.id)))
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 24) {
+ Text(filter.title).font(TVTypography.sectionTitle)
+ TextField("Search \(filter.title)", text: $searchText)
+ .font(TVTypography.body)
+ Group {
+ if let error {
+ ContentUnavailableView {
+ Label("Couldn’t Load \(filter.title)", systemImage: "exclamationmark.triangle")
+ } description: { Text(error) } actions: {
+ Button("Try Again") { reloadID = UUID() }
+ }
+ } else if !isLoaded {
+ ProgressView("Loading \(filter.title)…")
+ } else if values.isEmpty {
+ ContentUnavailableView("No \(filter.title) Values", systemImage: "line.3.horizontal.decrease")
+ } else if filteredValues.isEmpty {
+ ContentUnavailableView.search(text: searchText)
+ } else {
+ List(filteredValues) { value in
+ Button {
+ if !selectedIDs.insert(value.id).inserted {
+ selectedIDs.remove(value.id)
+ }
+ } label: {
+ HStack {
+ Text(value.title)
+ Spacer()
+ if selectedIDs.contains(value.id) { Image(systemName: "checkmark") }
+ }
+ }
+ .font(TVTypography.body)
+ .accessibilityIdentifier("filter-value.\(value.id)")
+ .accessibilityValue(selectedIDs.contains(value.id) ? "Selected" : "Not selected")
+ .accessibilityAddTraits(selectedIDs.contains(value.id) ? .isSelected : [])
+ }
+ .listStyle(.plain)
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .frame(height: 360)
+ HStack(spacing: 24) {
+ Button("Clear") { selectedIDs.removeAll() }.disabled(selectedIDs.isEmpty)
+ Spacer()
+ Button("Cancel") { dismiss() }
+ Button(selectedIDs.isEmpty ? "Apply" : "Apply (\(selectedIDs.count))") {
+ apply(values.filter { selectedIDs.contains($0.id) })
+ dismiss()
+ }.disabled(!isLoaded)
+ }
+ .font(TVTypography.action)
+ .focusSection()
+ }
+ .padding(40)
+ .frame(width: 800)
+ .presentationSizing(.fitted)
+ .task(id: reloadID) {
+ error = nil
+ do {
+ let result = try await store.libraryFilterValues(filter)
+ try Task.checkCancellation()
+ values = result
+ isLoaded = true
+ } catch is CancellationError {
+ return
+ } catch {
+ guard !Task.isCancelled else { return }
+ self.error = error.localizedDescription
+ }
+ }
+ }
+
+ private var filteredValues: [PlexLibraryFilterValue] {
+ let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
+ return query.isEmpty ? values : values.filter { $0.title.localizedStandardContains(query) }
+ }
+}
diff --git a/PlexBar/TV/Views/TVMediaDetailView.swift b/PlexBar/TV/Views/TVMediaDetailView.swift
new file mode 100644
index 0000000..e2f1adb
--- /dev/null
+++ b/PlexBar/TV/Views/TVMediaDetailView.swift
@@ -0,0 +1,326 @@
+import PlexModels
+import SwiftUI
+
+struct TVMediaDetailView: View {
+ @Environment(TVAppStore.self) private var store
+ let seedItem: PlexMediaItem
+
+ @State private var resolvedItem: PlexMediaItem?
+ @State private var selectedEpisode: PlexMediaItem?
+ @State private var children: [PlexMediaItem] = []
+ @State private var isLoading = false
+ @State private var selectedPlaybackVersionID: Int?
+ @State private var showsSummary = false
+ @State private var detailError: String?
+ @State private var castError: String?
+ @State private var episodeSeriesCast: [PlexTag] = []
+ @State private var detailReloadID = UUID()
+ @State private var castReloadID = UUID()
+ @FocusState private var isPlaybackFocused: Bool
+
+ var body: some View {
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: TVLayout.sectionSpacing) {
+ overview
+
+ if let detailError {
+ loadError(title: "Couldn’t Load Details", message: detailError) {
+ detailReloadID = UUID()
+ }
+ }
+
+ if ["show", "season", "episode"].contains(item.type?.lowercased() ?? "") {
+ TVSeasonEpisodeBrowser(item: item, selectEpisode: selectEpisode)
+ } else if !children.isEmpty {
+ TVMediaShelf(
+ title: childrenTitle,
+ items: children,
+ artworkPreference: childrenArtworkPreference,
+ showsEpisodeNumbers: item.type == "episode" || item.type == "season"
+ )
+ } else if isLoading, !item.isPlayable {
+ ProgressView("Loading \(childrenTitle.lowercased())…")
+ .safeAreaPadding(.horizontal)
+ }
+
+ if !people.cast.isEmpty {
+ peopleShelf(title: "Cast", credits: people.cast)
+ }
+
+ if !people.crew.isEmpty {
+ peopleShelf(title: "Crew", credits: people.crew)
+ }
+
+ if let castError {
+ loadError(title: "Couldn’t Load Cast", message: castError) {
+ castReloadID = UUID()
+ }
+ }
+
+ PlexMediaMetadataView(item: item)
+ .frame(maxWidth: 980, alignment: .leading)
+ .safeAreaPadding(.horizontal)
+
+ TVDetailDiscoveryView(item: item)
+ .id(item.id)
+ }
+ .safeAreaPadding(.bottom)
+ .background(alignment: .top) {
+ TVArtworkImage(path: item.preferredBackdropPath, width: 1920, height: 1080) { image in
+ PlexCinematicBackdrop(
+ image: image,
+ pageBackground: TVTheme.canvas,
+ contentPlacement: .leading
+ )
+ }
+ .containerRelativeFrame(.vertical)
+ .ignoresSafeArea(edges: .horizontal)
+ }
+ }
+ .scrollClipDisabled()
+ .defaultFocus($isPlaybackFocused, true, priority: .userInitiated)
+ .ignoresSafeArea(.container, edges: .top)
+ .background(TVCanvasBackground())
+ .toolbarVisibility(.hidden, for: .tabBar)
+ .sheet(isPresented: $showsSummary) {
+ VStack(alignment: .leading, spacing: 28) {
+ Text(item.title)
+ .font(TVTypography.sectionTitle)
+ .lineLimit(2)
+ ScrollView {
+ Text(item.summary ?? "")
+ .font(TVTypography.body)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .focusable()
+ }
+ .frame(height: 280)
+ Button("Done") { showsSummary = false }
+ }
+ .padding(60)
+ .frame(width: 1100)
+ .presentationSizing(.fitted)
+ }
+ .task(id: DetailsIdentity(itemID: selectedEpisode?.id ?? seedItem.id, playbackRevision: store.playbackMetadataRevision, reloadID: detailReloadID)) {
+ await loadDetails()
+ }
+ .task(id: DetailsIdentity(itemID: item.episodeSeriesCastRatingKey ?? "", playbackRevision: store.playbackMetadataRevision, reloadID: castReloadID)) {
+ await loadSeriesCast()
+ }
+ .navigationDestination(for: TVNavigationRoute.self) { route in
+ TVNavigationDestination(route: route)
+ }
+ }
+
+ private var item: PlexMediaItem {
+ resolvedItem ?? selectedEpisode ?? seedItem
+ }
+
+ private struct DetailsIdentity: Hashable {
+ let itemID: String
+ let playbackRevision: UUID
+ let reloadID: UUID
+ }
+
+ private var people: PlexCastAndCrewPresentation {
+ PlexCastAndCrewPresentation(item: item, episodeSeriesCast: episodeSeriesCast)
+ }
+
+ private var overview: some View {
+ TVCinematicMediaHero(item: item, showSummary: { showsSummary = true }) {
+ actionButtons
+ }
+ }
+
+ private var actionButtons: some View {
+ VStack(alignment: .leading, spacing: 24) {
+ HStack(spacing: 16) {
+ if item.tvCanStartPlayback {
+ TVPlaybackButton(item: item, title: item.tvResumeTitle, fillsAvailableWidth: false,
+ isIconOnly: item.resumeSeconds <= 0, action: play)
+ .accessibilityIdentifier("detail-play.\(item.ratingKey)")
+ .focused($isPlaybackFocused)
+ }
+
+ if let primaryExtraTitle = item.primaryExtraActionTitle {
+ TVPlaybackButton(
+ item: item,
+ title: primaryExtraTitle,
+ systemImage: "play.rectangle.fill",
+ preparationKind: .primaryExtra,
+ fillsAvailableWidth: false,
+ isIconOnly: true,
+ action: playPrimaryExtra
+ )
+ }
+
+ if item.isPlayable, item.resumeSeconds > 0 {
+ Button("Play from Beginning", systemImage: "arrow.counterclockwise", action: playFromBeginning)
+ .labelStyle(.iconOnly)
+ .font(TVTypography.action)
+ .accessibilityLabel("Play from Beginning")
+ }
+
+ if playbackVersionOptions.count > 1 {
+ Menu {
+ Picker("Version", selection: playbackVersionBinding) {
+ ForEach(playbackVersionOptions) { option in
+ Text(option.label).tag(option.id)
+ }
+ }
+ .pickerStyle(.inline)
+ } label: {
+ Label("Version", systemImage: "square.stack")
+ .labelStyle(.iconOnly)
+ .font(TVTypography.action)
+ }
+ .accessibilityLabel("Version")
+ .accessibilityValue(selectedPlaybackVersion?.label ?? "")
+ .accessibilityIdentifier("detail-version-picker")
+ }
+ }
+ .fixedSize(horizontal: true, vertical: false)
+ }
+ .controlSize(.regular)
+ }
+
+ private func peopleShelf(
+ title: String,
+ credits: [PlexCastAndCrewCredit]
+ ) -> some View {
+ VStack(alignment: .leading, spacing: 16) {
+ Text(title)
+ .font(TVTypography.sectionTitle)
+ .safeAreaPadding(.horizontal)
+ .accessibilityAddTraits(.isHeader)
+
+ ScrollView(.horizontal) {
+ LazyHStack(alignment: .top, spacing: TVLayout.cardSpacing) {
+ ForEach(credits) { credit in
+ TVPersonLockup(credit: credit)
+ }
+ }
+ .safeAreaPadding(.horizontal)
+ }
+ .scrollClipDisabled()
+ .buttonStyle(.borderless)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .accessibilityElement(children: .contain)
+ .accessibilityLabel(title)
+ .focusSection()
+ }
+
+ private var childrenTitle: String {
+ switch item.type?.lowercased() {
+ case "show": "Seasons"
+ case "season": "Episodes"
+ case "episode": item.parentTitle ?? "Episodes"
+ case "artist": "Albums"
+ case "album": "Tracks"
+ default: "More"
+ }
+ }
+
+ private var childrenArtworkPreference: TVMediaShelf.ArtworkPreference {
+ switch item.type?.lowercased() {
+ case "show": .automatic
+ case "season", "episode": .landscape
+ default: .automatic
+ }
+ }
+
+ private var playbackVersionOptions: [PlexPlaybackVersionOption] {
+ item.playbackVersionOptions
+ }
+
+ private var selectedPlaybackVersion: PlexPlaybackVersionOption? {
+ let selectedID = selectedPlaybackVersionID
+ ?? item.defaultPlaybackSource?.mediaIndex
+ return playbackVersionOptions.first { $0.id == selectedID }
+ }
+
+ private var playbackVersionBinding: Binding {
+ Binding(
+ get: {
+ selectedPlaybackVersion?.id
+ ?? playbackVersionOptions.first?.id
+ ?? 0
+ },
+ set: { selectedPlaybackVersionID = $0 }
+ )
+ }
+
+ private func loadDetails() async {
+ isLoading = true
+ detailError = nil
+ do {
+ let value = try await store.resolvedItem(selectedEpisode ?? seedItem)
+ guard !Task.isCancelled else { return }
+ resolvedItem = value
+ if !value.playbackVersionOptions.contains(where: {
+ $0.id == selectedPlaybackVersionID
+ }) {
+ selectedPlaybackVersionID = value.defaultPlaybackSource?.mediaIndex
+ }
+ if !value.isPlayable, !["show", "season"].contains(value.type?.lowercased() ?? "") {
+ let loadedChildren = try await store.children(of: value)
+ guard !Task.isCancelled else { return }
+ children = loadedChildren
+ }
+ } catch {
+ guard !Task.isCancelled else { return }
+ detailError = error.localizedDescription
+ }
+ isLoading = false
+ }
+
+ private func loadSeriesCast() async {
+ episodeSeriesCast = []
+ castError = nil
+ guard item.episodeSeriesCastRatingKey != nil else { return }
+ do {
+ let cast = try await store.episodeSeriesCast(for: item)
+ guard !Task.isCancelled else { return }
+ episodeSeriesCast = cast
+ } catch {
+ guard !Task.isCancelled else { return }
+ castError = error.localizedDescription
+ }
+ }
+
+ private func loadError(title: String, message: String, retry: @escaping () -> Void) -> some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Text(title)
+ .font(TVTypography.sectionTitle)
+ Text(message)
+ .font(TVTypography.metadata)
+ .foregroundStyle(.secondary)
+ Button("Try Again", systemImage: "arrow.clockwise", action: retry)
+ .font(TVTypography.action)
+ }
+ .safeAreaPadding(.horizontal)
+ }
+
+ private func play() {
+ store.play(item, source: selectedPlaybackVersion?.source)
+ }
+
+ private func selectEpisode(_ episode: PlexMediaItem) {
+ guard episode.ratingKey != item.ratingKey else { return }
+ selectedEpisode = episode
+ resolvedItem = nil
+ selectedPlaybackVersionID = nil
+ }
+
+ private func playFromBeginning() {
+ store.play(
+ item,
+ source: selectedPlaybackVersion?.source,
+ resume: false
+ )
+ }
+
+ private func playPrimaryExtra() {
+ store.playPrimaryExtra(for: item)
+ }
+}
diff --git a/PlexBar/TV/Views/TVNavigationDestination.swift b/PlexBar/TV/Views/TVNavigationDestination.swift
new file mode 100644
index 0000000..2c5039f
--- /dev/null
+++ b/PlexBar/TV/Views/TVNavigationDestination.swift
@@ -0,0 +1,12 @@
+import SwiftUI
+
+struct TVNavigationDestination: View {
+ let route: TVNavigationRoute
+
+ var body: some View {
+ switch route {
+ case .media(let item): TVMediaDetailView(seedItem: item)
+ case .person(let route): TVPersonDetailView(route: route)
+ }
+ }
+}
diff --git a/PlexBar/TV/Views/TVPersonDetailView.swift b/PlexBar/TV/Views/TVPersonDetailView.swift
new file mode 100644
index 0000000..879f36e
--- /dev/null
+++ b/PlexBar/TV/Views/TVPersonDetailView.swift
@@ -0,0 +1,103 @@
+import PlexModels
+import SwiftUI
+
+struct TVPersonDetailView: View {
+ @Environment(TVAppStore.self) private var store
+
+ let route: PlexPersonRoute
+
+ @State private var person: PlexTag?
+ @State private var media: [PlexMediaItem] = []
+ @State private var isLoading = true
+ @State private var errorMessage: String?
+
+ var body: some View {
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: TVLayout.sectionSpacing) {
+ header
+
+ if isLoading, media.isEmpty {
+ ProgressView("Loading Appearances…")
+ .safeAreaPadding(.horizontal)
+ } else if let errorMessage, media.isEmpty {
+ ContentUnavailableView {
+ Label("Couldn’t Load Appearances", systemImage: "exclamationmark.triangle")
+ } description: {
+ Text(errorMessage)
+ } actions: {
+ Button("Try Again", systemImage: "arrow.clockwise", action: refresh)
+ }
+ } else if media.isEmpty {
+ ContentUnavailableView(
+ "No Appearances in This Library",
+ systemImage: "rectangle.stack"
+ )
+ } else {
+ VStack(alignment: .leading, spacing: 16) {
+ Text("In Your Library")
+ .font(TVTypography.sectionTitle)
+ .accessibilityAddTraits(.isHeader)
+ TVMediaGrid(items: media, artworkLayout: .poster)
+ }
+ .safeAreaPadding(.horizontal)
+ }
+ }
+ .safeAreaPadding(.top, 40)
+ .safeAreaPadding(.bottom)
+ }
+ .scrollClipDisabled()
+ .background(TVCanvasBackground())
+ .toolbarVisibility(.hidden, for: .tabBar)
+ .task(id: route) {
+ await load()
+ }
+ .navigationDestination(for: TVNavigationRoute.self) { route in
+ TVNavigationDestination(route: route)
+ }
+ }
+
+ private var header: some View {
+ HStack(alignment: .top, spacing: 24) {
+ TVPlexArtwork(
+ path: person?.thumb?.nilIfBlank ?? route.thumb,
+ width: 400,
+ height: 400,
+ systemImage: "person.fill"
+ )
+ .aspectRatio(1, contentMode: .fit)
+ .frame(width: 180, height: 180)
+ .clipShape(.rect(cornerRadius: 20))
+
+ Text(displayName)
+ .font(TVTypography.title)
+ .lineLimit(2)
+ }
+ .safeAreaPadding(.horizontal)
+ .accessibilityElement(children: .combine)
+ }
+
+ private var displayName: String {
+ person?.tag.nilIfBlank ?? route.name
+ }
+
+ private func refresh() {
+ Task { await load() }
+ }
+
+ private func load() async {
+ isLoading = true
+ errorMessage = nil
+ do {
+ let details = try await store.personDetails(for: route)
+ try Task.checkCancellation()
+ person = details.person
+ media = details.media
+ } catch is CancellationError {
+ return
+ } catch {
+ guard !Task.isCancelled else { return }
+ errorMessage = error.localizedDescription
+ }
+ isLoading = false
+ }
+}
diff --git a/PlexBar/TV/Views/TVSearchHubGrid.swift b/PlexBar/TV/Views/TVSearchHubGrid.swift
new file mode 100644
index 0000000..b48bbf4
--- /dev/null
+++ b/PlexBar/TV/Views/TVSearchHubGrid.swift
@@ -0,0 +1,23 @@
+import SwiftUI
+
+struct TVSearchHubGrid: View {
+ @Environment(TVAppStore.self) private var store
+ let hub: PlexHub
+ @State private var browser = TVHubBrowseStore()
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ Text(hub.title)
+ .font(TVTypography.sectionTitle)
+ .accessibilityAddTraits(.isHeader)
+ TVMediaGrid(
+ items: browser.visibleItems(hub: hub, connection: store.connection),
+ artworkLayout: hub.prefersPosterArtwork ? .poster : .automatic
+ ) { item in
+ await browser.loadInline(hub: hub, using: store, after: item)
+ }
+ TVHubLoadingStatus(hub: hub, browser: browser)
+ }
+ .focusSection()
+ }
+}
diff --git a/PlexBar/TV/Views/TVSearchView.swift b/PlexBar/TV/Views/TVSearchView.swift
new file mode 100644
index 0000000..af347a7
--- /dev/null
+++ b/PlexBar/TV/Views/TVSearchView.swift
@@ -0,0 +1,63 @@
+import SwiftUI
+
+struct TVSearchView: View {
+ @Environment(TVAppStore.self) private var store
+
+ var body: some View {
+ @Bindable var store = store
+
+ NavigationStack {
+ Group {
+ if store.isSearching, visibleHubs.isEmpty {
+ TVLoadingView(title: "Searching…")
+ } else if store.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ ContentUnavailableView(
+ "Search Your Plex",
+ systemImage: "magnifyingglass",
+ description: Text("Find movies, shows, episodes, music, and collections.")
+ )
+ } else if let error = store.searchErrorMessage {
+ ContentUnavailableView {
+ Label("Couldn’t Search Plex", systemImage: "magnifyingglass")
+ } description: {
+ Text(error)
+ } actions: {
+ Button("Try Again", systemImage: "arrow.clockwise") { store.submitSearch() }
+ }
+ } else if visibleHubs.isEmpty {
+ ContentUnavailableView.search(text: store.searchQuery)
+ } else {
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: TVLayout.sectionSpacing) {
+ ForEach(visibleHubs) { hub in
+ TVSearchHubGrid(hub: hub)
+ }
+ }
+ .buttonStyle(.borderless)
+ .safeAreaPadding(.horizontal)
+ .safeAreaPadding(.vertical)
+ }
+ .scrollClipDisabled()
+ }
+ }
+ .background(TVCanvasBackground())
+ .searchable(text: $store.searchQuery, prompt: "Movies, shows, episodes, and music")
+ .onSubmit(of: .search) {
+ store.submitSearch()
+ }
+ .onChange(of: store.searchQuery) {
+ store.submitSearch()
+ }
+ .onChange(of: store.connection) {
+ store.submitSearch()
+ }
+ .navigationDestination(for: TVNavigationRoute.self) { route in
+ TVNavigationDestination(route: route)
+ }
+ }
+ }
+
+ private var visibleHubs: [PlexHub] {
+ store.searchHubs.filter { !$0.metadata.isEmpty }
+ }
+}
diff --git a/PlexBar/TV/Views/TVSeasonEpisodeBrowser.swift b/PlexBar/TV/Views/TVSeasonEpisodeBrowser.swift
new file mode 100644
index 0000000..a928a4f
--- /dev/null
+++ b/PlexBar/TV/Views/TVSeasonEpisodeBrowser.swift
@@ -0,0 +1,102 @@
+import PlexModels
+import SwiftUI
+
+/// Keep the season picker available when an episode is opened directly from Home.
+struct TVSeasonEpisodeBrowser: View {
+ @Environment(TVAppStore.self) private var store
+ let item: PlexMediaItem
+ let selectEpisode: (PlexMediaItem) -> Void
+
+ @State private var seasons: [PlexMediaItem] = []
+ @State private var selectedSeasonKey: String?
+ @State private var loadedSeasonKey: String?
+ @State private var episodes: [PlexMediaItem] = []
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ header
+ .safeAreaPadding(.horizontal)
+
+ if let seasonKey {
+ if loadedSeasonKey != seasonKey {
+ ProgressView("Loading episodes…")
+ .safeAreaPadding(.horizontal)
+ } else if episodes.isEmpty {
+ ContentUnavailableView("No Episodes", systemImage: "tv")
+ } else {
+ TVMediaShelf(
+ title: "",
+ items: episodes,
+ artworkPreference: .landscape,
+ columnCount: TVLayout.episodeColumns,
+ showsEpisodeNumbers: true,
+ selectedItemID: item.type?.lowercased() == "episode" ? item.id : nil,
+ selectItem: selectEpisode
+ )
+ .accessibilityLabel(seasonTitle)
+ }
+ }
+ }
+ .focusSection()
+ .task(id: seriesKey) {
+ guard let seriesKey else { return }
+ let loaded = await store.seriesSeasons(ratingKey: seriesKey)
+ guard !Task.isCancelled else { return }
+ seasons = loaded
+ }
+ .task(id: EpisodeLoadIdentity(seasonKey: seasonKey, revision: store.playbackMetadataRevision)) {
+ guard let seasonKey else { return }
+ let loaded = await store.seasonEpisodes(ratingKey: seasonKey)
+ guard !Task.isCancelled else { return }
+ episodes = loaded
+ loadedSeasonKey = seasonKey
+ }
+ }
+
+ @ViewBuilder
+ private var header: some View {
+ if seasons.count > 1, let seasonKey, seasons.contains(where: { $0.ratingKey == seasonKey }) {
+ PlexSeasonPicker(seasons: seasons, selection: Binding(
+ get: { seasonKey },
+ set: { selectedSeasonKey = $0 }
+ ))
+ } else {
+ Text(seasonTitle)
+ .font(TVTypography.sectionTitle)
+ .accessibilityAddTraits(.isHeader)
+ }
+ }
+
+ private var seasonKey: String? {
+ if let selectedSeasonKey { return selectedSeasonKey }
+ switch item.type?.lowercased() {
+ case "episode": return item.parentRatingKey
+ case "season": return item.ratingKey
+ default: return seasons.first?.ratingKey
+ }
+ }
+
+ private var seriesKey: String? {
+ switch item.type?.lowercased() {
+ case "episode": item.grandparentRatingKey
+ case "season": item.parentRatingKey
+ default: item.ratingKey
+ }
+ }
+
+ private var seasonTitle: String {
+ if let season = seasons.first(where: { $0.ratingKey == seasonKey }) {
+ return season.title
+ }
+ switch item.type?.lowercased() {
+ case "episode": return item.parentTitle ?? "Episodes"
+ case "season": return item.title
+ default: return "Episodes"
+ }
+ }
+
+ private struct EpisodeLoadIdentity: Hashable {
+ let seasonKey: String?
+ let revision: UUID
+ }
+}
diff --git a/PlexBar/TV/Views/TVSettingsView.swift b/PlexBar/TV/Views/TVSettingsView.swift
new file mode 100644
index 0000000..f3e8bf8
--- /dev/null
+++ b/PlexBar/TV/Views/TVSettingsView.swift
@@ -0,0 +1,334 @@
+import SwiftUI
+
+struct TVSettingsView: View {
+ @Environment(TVAppStore.self) private var store
+ @State private var showsSignOutConfirmation = false
+
+ var body: some View {
+ NavigationStack {
+ settingsIndex
+ .navigationDestination(for: TVSettingsDestination.self) { destination in
+ settingsPage(destination)
+ }
+ }
+ .confirmationDialog(
+ "Sign Out of PlexBar?",
+ isPresented: $showsSignOutConfirmation,
+ titleVisibility: .visible
+ ) {
+ Button("Sign Out", role: .destructive) {
+ Task { await store.logout() }
+ }
+ Button("Cancel", role: .cancel) {}
+ } message: {
+ Text("PlexBar will remove this account and its saved server from this Apple TV.")
+ }
+ }
+
+ private var settingsIndex: some View {
+ TVSettingsForm {
+ Section("Plex") {
+ NavigationLink(value: TVSettingsDestination.server) {
+ TVSettingsNavigationRow(
+ title: "Plex Server",
+ systemImage: "server.rack",
+ value: store.serverName
+ )
+ }
+ }
+
+ Section("Playback") {
+ ForEach(TVSettingsDestination.playbackDestinations) { destination in
+ NavigationLink(value: destination) {
+ Label(destination.title, systemImage: destination.systemImage)
+ }
+ }
+ }
+
+ Section("Account") {
+ Button(
+ "Sign Out",
+ systemImage: "rectangle.portrait.and.arrow.right",
+ role: .destructive
+ ) {
+ showsSignOutConfirmation = true
+ }
+ }
+ }
+ .navigationTitle("Settings")
+ .accessibilityLabel("Settings")
+ }
+
+ private func settingsPage(_ destination: TVSettingsDestination) -> some View {
+ TVSettingsForm {
+ switch destination {
+ case .server:
+ connectionSections
+ case .video:
+ videoSections
+ case .audio:
+ audioSections
+ case .subtitles:
+ subtitleSections
+ case .advanced:
+ advancedSections
+ }
+ }
+ .navigationTitle(destination.title)
+ }
+
+ @ViewBuilder
+ private var connectionSections: some View {
+ Section("Current Server") {
+ LabeledContent("Name", value: store.serverName)
+ if let connectionKind = store.connection?.kind {
+ LabeledContent("Connection", value: connectionKind.displayName)
+ }
+ }
+
+ Section {
+ Button("Refresh Libraries", systemImage: "arrow.clockwise") {
+ Task { await store.refreshAll() }
+ }
+ Button("Change Server", systemImage: "arrow.triangle.branch") {
+ Task { await store.chooseServer() }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var videoSections: some View {
+ @Bindable var store = store
+
+ Section {
+ Picker(
+ store.automaticallyAdjustVideoQuality
+ ? "Home Starting Quality"
+ : "Home Streaming Quality",
+ selection: $store.localVideoQuality
+ ) {
+ ForEach(PlexVideoQuality.allCases) { quality in
+ Text(quality.label).tag(quality)
+ }
+ }
+ Picker(
+ store.automaticallyAdjustVideoQuality
+ ? "Remote Starting Quality"
+ : "Remote Streaming Quality",
+ selection: $store.remoteVideoQuality
+ ) {
+ ForEach(PlexVideoQuality.allCases) { quality in
+ Text(quality.label).tag(quality)
+ }
+ }
+ Toggle(
+ "Play Smaller Remote Videos at Original Quality",
+ isOn: $store.playSmallerVideosAtOriginalQuality
+ )
+ Toggle("Automatically Adjust Quality", isOn: $store.automaticallyAdjustVideoQuality)
+ Toggle("Suggest Quality Changes", isOn: $store.qualitySuggestionsEnabled)
+ .disabled(store.automaticallyAdjustVideoQuality)
+ } header: {
+ Text("Streaming Quality")
+ } footer: {
+ Text(
+ "Converted video starts at the selected quality. When automatic adjustment is on, Plex and Apple TV adapt the stream as connection conditions change. Original-quality playback is unchanged."
+ )
+ }
+
+ Section("Presentation") {
+ Picker("Video Scaling", selection: $store.videoScalingMode) {
+ ForEach(PlexVideoScalingMode.allCases) { scalingMode in
+ Text(scalingMode.label).tag(scalingMode)
+ }
+ }
+ Picker("Cinema Experience", selection: $store.cinemaPreplayPreference) {
+ ForEach(PlexCinemaPreplayPreference.allCases) { preference in
+ Text(preference.label).tag(preference)
+ }
+ }
+ Picker(
+ "Rewind on Resume",
+ selection: Binding(
+ get: { store.rewindOnResume.seconds },
+ set: { store.rewindOnResume = PlexRewindOnResume(seconds: $0) }
+ )
+ ) {
+ ForEach(PlexRewindOnResume.secondsRange, id: \.self) { seconds in
+ Text(PlexRewindOnResume(seconds: seconds).label).tag(seconds)
+ }
+ }
+ .accessibilityLabel("Rewind on Resume")
+ .accessibilityValue(store.rewindOnResume.label)
+ }
+
+ Section("Episode Playback") {
+ markerBehaviorPicker("Skip Intros", selection: $store.skipIntroBehavior)
+ markerBehaviorPicker("Skip Ads", selection: $store.skipAdsBehavior)
+ markerBehaviorPicker("Skip Credits", selection: $store.skipCreditsBehavior)
+ Toggle("Automatically Play Next Episode", isOn: $store.autoplayNextEpisode)
+ if store.autoplayNextEpisode {
+ Picker("Play Next Episode", selection: $store.autoplayCountdown) {
+ ForEach(PlexAutoplayCountdown.allCases) { countdown in
+ Text(countdown.label).tag(countdown)
+ }
+ }
+ Picker("Are You Still Watching?", selection: $store.passoutProtection) {
+ ForEach(PlexPassoutProtection.allCases) { protection in
+ Text(protection.label).tag(protection)
+ }
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var audioSections: some View {
+ @Bindable var store = store
+
+ Section {
+ Picker("Remote Music Quality", selection: $store.remoteMusicQuality) {
+ ForEach(PlexMusicQuality.allCases) { quality in
+ Text(quality.label).tag(quality)
+ }
+ }
+ } footer: {
+ Text(
+ "Music on your home network plays at original quality. This limit applies only when streaming from a remote Plex server."
+ )
+ }
+
+ Section {
+ Picker("Multichannel Audio Boost", selection: $store.audioBoost) {
+ ForEach(PlexAudioBoost.allCases) { boost in
+ Text("\(boost.label) · \(boost.percentageLabel)").tag(boost)
+ }
+ }
+ } footer: {
+ Text(
+ "Boost applies only when Plex converts multichannel audio to stereo. Original surround and stereo playback are unchanged."
+ )
+ }
+ }
+
+ @ViewBuilder
+ private var subtitleSections: some View {
+ @Bindable var store = store
+
+ Section {
+ Picker("Subtitle Size", selection: $store.subtitleSize) {
+ ForEach(PlexSubtitleSize.allCases) { size in
+ Text("\(size.label) · \(size.percentageLabel)").tag(size)
+ }
+ }
+ Toggle("Auto-Sync Compatible Subtitles", isOn: $store.automaticallySyncSubtitles)
+ } footer: {
+ Text(
+ "When Plex has analyzed the video and the selected subtitle supports it, auto-sync aligns subtitle timing to detected dialogue."
+ )
+ }
+
+ Section {
+ Picker("Burn Subtitles", selection: $store.subtitleBurnMode) {
+ ForEach(PlexSubtitleBurnMode.allCases) { mode in
+ Text(mode.label).tag(mode)
+ }
+ }
+ } footer: {
+ Text(store.subtitleBurnMode.explanation)
+ }
+ }
+
+ @ViewBuilder
+ private var advancedSections: some View {
+ @Bindable var store = store
+
+ Section {
+ Toggle("Allow Direct Play", isOn: $store.allowsDirectPlay)
+ Toggle("Allow Direct Stream", isOn: $store.allowsDirectStream)
+ Toggle("Force Direct Play", isOn: $store.forceDirectPlay)
+ .disabled(!store.allowsDirectPlay)
+ } footer: {
+ Text(
+ "Force Direct Play bypasses the server decision only when this Apple TV proves the exact file is natively compatible. Changes apply to the next item or playback reload."
+ )
+ }
+ }
+
+ private func markerBehaviorPicker(
+ _ title: String,
+ selection: Binding
+ ) -> some View {
+ Picker(title, selection: selection) {
+ ForEach(PlexPlaybackMarkerBehavior.allCases) { behavior in
+ Text(behavior.label).tag(behavior)
+ }
+ }
+ }
+}
+
+private enum TVSettingsDestination: String, Hashable, Identifiable {
+ case server
+ case video
+ case audio
+ case subtitles
+ case advanced
+
+ static let playbackDestinations: [TVSettingsDestination] = [
+ .video,
+ .audio,
+ .subtitles,
+ .advanced,
+ ]
+
+ var id: Self { self }
+
+ var title: String {
+ switch self {
+ case .server: "Plex Server"
+ case .video: "Video"
+ case .audio: "Audio"
+ case .subtitles: "Subtitles"
+ case .advanced: "Advanced"
+ }
+ }
+
+ var systemImage: String {
+ switch self {
+ case .server: "server.rack"
+ case .video: "play.tv.fill"
+ case .audio: "waveform"
+ case .subtitles: "captions.bubble.fill"
+ case .advanced: "slider.horizontal.3"
+ }
+ }
+}
+
+private struct TVSettingsForm: View {
+ @ViewBuilder let content: Content
+
+ var body: some View {
+ GeometryReader { proxy in
+ Form {
+ content
+ }
+ .safeAreaPadding(.horizontal, proxy.size.width / 5)
+ .safeAreaPadding(.vertical)
+ }
+ }
+}
+
+private struct TVSettingsNavigationRow: View {
+ let title: String
+ let systemImage: String
+ let value: String
+
+ var body: some View {
+ LabeledContent {
+ Text(value)
+ .foregroundStyle(.secondary)
+ } label: {
+ Label(title, systemImage: systemImage)
+ }
+ }
+}
diff --git a/PlexBar/TopShelf/Extension/ContentProvider.swift b/PlexBar/TopShelf/Extension/ContentProvider.swift
new file mode 100644
index 0000000..807386e
--- /dev/null
+++ b/PlexBar/TopShelf/Extension/ContentProvider.swift
@@ -0,0 +1,16 @@
+import os
+import TVServices
+
+final class ContentProvider: TVTopShelfContentProvider {
+ override func loadTopShelfContent() async -> (any TVTopShelfContent)? {
+ do {
+ let cache = try TVTopShelfCache.shared()
+ guard let snapshot = try cache.read() else { return nil }
+ return TVTopShelfContentBuilder.make(snapshot: snapshot, cache: cache)
+ } catch {
+ Logger(subsystem: "com.crapshack.PlexBar.tv.topshelf", category: "TopShelf")
+ .error("Unable to load Top Shelf: \(error.localizedDescription, privacy: .public)")
+ return nil
+ }
+ }
+}
diff --git a/PlexBar/TopShelf/Shared/TVTopShelfCache.swift b/PlexBar/TopShelf/Shared/TVTopShelfCache.swift
new file mode 100644
index 0000000..9297ab9
--- /dev/null
+++ b/PlexBar/TopShelf/Shared/TVTopShelfCache.swift
@@ -0,0 +1,90 @@
+import CryptoKit
+import Foundation
+
+struct TVTopShelfCache: Sendable {
+ static let appGroupIdentifier = "group.com.crapshack.PlexBar.tv"
+ let directory: URL
+
+ init(directory: URL) {
+ self.directory = directory
+ }
+
+ static func shared() throws -> Self {
+ guard let container = FileManager.default.containerURL(
+ forSecurityApplicationGroupIdentifier: appGroupIdentifier
+ ) else { throw CacheError.missingAppGroup }
+ // tvOS may purge caches. Missing content deliberately returns the static Top Shelf image.
+ return Self(directory: container.appending(path: "Library/Caches/TopShelf", directoryHint: .isDirectory))
+ }
+
+ private var manifestURL: URL { directory.appending(path: "content.json") }
+
+ func read() throws -> TVTopShelfSnapshot? {
+ guard FileManager.default.fileExists(atPath: manifestURL.path) else { return nil }
+ let snapshot = try JSONDecoder().decode(TVTopShelfSnapshot.self, from: Data(contentsOf: manifestURL))
+ guard snapshot.version == TVTopShelfSnapshot.currentVersion else { throw CacheError.unsupportedVersion }
+ return snapshot
+ }
+
+ func imageURL(filename: String) -> URL? {
+ // Only content-addressed local JPEGs may be passed to TVServices.
+ guard filename.hasSuffix(".jpg"), filename.count == 68,
+ filename.dropLast(4).utf8.allSatisfy({ (48...57).contains($0) || (97...102).contains($0) }) else { return nil }
+ return directory.appending(path: filename)
+ }
+
+ @discardableResult
+ func storeImage(_ data: Data) throws -> String {
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ let filename = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + ".jpg"
+ let url = directory.appending(path: filename)
+ if !FileManager.default.fileExists(atPath: url.path) {
+ try data.write(to: url, options: .atomic)
+ } else {
+ // Retention starts at last publication, not the image's original download date.
+ try FileManager.default.setAttributes([.modificationDate: Date.now], ofItemAtPath: url.path)
+ }
+ return filename
+ }
+
+ func write(_ snapshot: TVTopShelfSnapshot) throws {
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ try JSONEncoder().encode(snapshot).write(to: manifestURL, options: .atomic)
+ }
+
+ func clear() throws {
+ // Invalidate the manifest first so a new extension request cannot load the previous session.
+ if FileManager.default.fileExists(atPath: manifestURL.path) {
+ try FileManager.default.removeItem(at: manifestURL)
+ }
+ if FileManager.default.fileExists(atPath: directory.path) {
+ try FileManager.default.removeItem(at: directory)
+ }
+ }
+
+ func pruneImages(keeping snapshot: TVTopShelfSnapshot, now: Date = .now) throws {
+ let keep = Set(snapshot.sections.flatMap(\.items).map(\.imageFilename))
+ let files = try FileManager.default.contentsOfDirectory(
+ at: directory, includingPropertiesForKeys: [.contentModificationDateKey]
+ )
+ // Retain old artwork for one day: the Home Screen may still be displaying a previous snapshot.
+ for file in files where file.pathExtension == "jpg" && !keep.contains(file.lastPathComponent) {
+ let values = try file.resourceValues(forKeys: [.contentModificationDateKey])
+ if let modified = values.contentModificationDate, now.timeIntervalSince(modified) > 86_400 {
+ try FileManager.default.removeItem(at: file)
+ }
+ }
+ }
+
+ enum CacheError: LocalizedError {
+ case missingAppGroup
+ case unsupportedVersion
+
+ var errorDescription: String? {
+ switch self {
+ case .missingAppGroup: "The Top Shelf shared app container is unavailable. Check App Groups signing."
+ case .unsupportedVersion: "The Top Shelf cache format is unsupported. Open PlexBar to refresh it."
+ }
+ }
+ }
+}
diff --git a/PlexBar/TopShelf/Shared/TVTopShelfContent.swift b/PlexBar/TopShelf/Shared/TVTopShelfContent.swift
new file mode 100644
index 0000000..1b00c39
--- /dev/null
+++ b/PlexBar/TopShelf/Shared/TVTopShelfContent.swift
@@ -0,0 +1,39 @@
+import Foundation
+import TVServices
+
+enum TVTopShelfContentBuilder {
+ static func make(snapshot: TVTopShelfSnapshot, cache: TVTopShelfCache) -> TVTopShelfSectionedContent? {
+ guard snapshot.version == TVTopShelfSnapshot.currentVersion,
+ !snapshot.serverIdentifier.isEmpty else { return nil }
+ var seen: Set = []
+ let sections = snapshot.sections.compactMap { section -> TVTopShelfItemCollection? in
+ let items = section.items.compactMap { entry -> TVTopShelfSectionedItem? in
+ guard TVTopShelfRoute.isValidRatingKey(entry.ratingKey),
+ let url = cache.imageURL(filename: entry.imageFilename),
+ FileManager.default.fileExists(atPath: url.path) else { return nil }
+ let route = TVTopShelfRoute(
+ action: .display, serverIdentifier: snapshot.serverIdentifier, ratingKey: entry.ratingKey
+ )
+ guard seen.insert(route.url.absoluteString).inserted else { return nil }
+ let item = TVTopShelfSectionedItem(identifier: route.url.absoluteString)
+ item.title = entry.title
+ item.imageShape = entry.shape == .poster ? .poster : .square
+ item.setImageURL(url, for: .screenScale1x)
+ item.setImageURL(url, for: .screenScale2x)
+ item.playbackProgress = entry.playbackProgress.isFinite ? min(max(entry.playbackProgress, 0), 1) : 0
+ item.displayAction = TVTopShelfAction(url: route.url)
+ if entry.canPlay {
+ item.playAction = TVTopShelfAction(url: TVTopShelfRoute(
+ action: .play, serverIdentifier: snapshot.serverIdentifier, ratingKey: entry.ratingKey
+ ).url)
+ }
+ return item
+ }
+ guard !items.isEmpty else { return nil }
+ let collection = TVTopShelfItemCollection(items: items)
+ collection.title = section.title
+ return collection
+ }
+ return sections.isEmpty ? nil : TVTopShelfSectionedContent(sections: sections)
+ }
+}
diff --git a/PlexBar/TopShelf/Shared/TVTopShelfRoute.swift b/PlexBar/TopShelf/Shared/TVTopShelfRoute.swift
new file mode 100644
index 0000000..9f2976f
--- /dev/null
+++ b/PlexBar/TopShelf/Shared/TVTopShelfRoute.swift
@@ -0,0 +1,51 @@
+import Foundation
+
+struct TVTopShelfRoute: Equatable, Sendable {
+ enum Action: String, Sendable {
+ case display
+ case play
+ }
+
+ let action: Action
+ let serverIdentifier: String
+ let ratingKey: String
+
+ init(action: Action, serverIdentifier: String, ratingKey: String) {
+ self.action = action
+ self.serverIdentifier = serverIdentifier
+ self.ratingKey = ratingKey
+ }
+
+ init?(url: URL) {
+ guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
+ components.scheme == "plexbar-tv", components.host == "topshelf",
+ components.user == nil, components.password == nil,
+ components.port == nil, components.fragment == nil,
+ let action = Action(rawValue: String(components.path.dropFirst())),
+ components.path == "/\(action.rawValue)",
+ let query = components.queryItems, query.count == 2,
+ query.filter({ $0.name == "server" }).count == 1,
+ query.filter({ $0.name == "item" }).count == 1,
+ let server = query.first(where: { $0.name == "server" })?.value,
+ !server.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
+ let key = query.first(where: { $0.name == "item" })?.value,
+ Self.isValidRatingKey(key) else { return nil }
+ self.init(action: action, serverIdentifier: server, ratingKey: key)
+ }
+
+ static func isValidRatingKey(_ key: String) -> Bool {
+ !key.isEmpty && key.utf8.allSatisfy { (48...57).contains($0) }
+ }
+
+ var url: URL {
+ var components = URLComponents()
+ components.scheme = "plexbar-tv"
+ components.host = "topshelf"
+ components.path = "/\(action.rawValue)"
+ components.queryItems = [
+ URLQueryItem(name: "server", value: serverIdentifier),
+ URLQueryItem(name: "item", value: ratingKey)
+ ]
+ return components.url!
+ }
+}
diff --git a/PlexBar/TopShelf/Shared/TVTopShelfSnapshot.swift b/PlexBar/TopShelf/Shared/TVTopShelfSnapshot.swift
new file mode 100644
index 0000000..6e34058
--- /dev/null
+++ b/PlexBar/TopShelf/Shared/TVTopShelfSnapshot.swift
@@ -0,0 +1,29 @@
+import Foundation
+
+/// Contains only presentation data. Account tokens and authenticated URLs never cross this boundary.
+struct TVTopShelfSnapshot: Codable, Equatable, Sendable {
+ static let currentVersion = 1
+ var version = currentVersion
+ let serverIdentifier: String
+ let sections: [Section]
+
+ struct Section: Codable, Equatable, Sendable {
+ let identifier: String
+ let title: String
+ let items: [Item]
+ }
+
+ struct Item: Codable, Equatable, Sendable {
+ enum Shape: String, Codable, Sendable {
+ case poster
+ case square
+ }
+
+ let ratingKey: String
+ let title: String
+ let imageFilename: String
+ let shape: Shape
+ let playbackProgress: Double
+ let canPlay: Bool
+ }
+}
diff --git a/Sources/PlexBar/Views/HistoryDashboardView.swift b/PlexBar/Views/HistoryDashboardView.swift
similarity index 83%
rename from Sources/PlexBar/Views/HistoryDashboardView.swift
rename to PlexBar/Views/HistoryDashboardView.swift
index 9554d05..8476ad5 100644
--- a/Sources/PlexBar/Views/HistoryDashboardView.swift
+++ b/PlexBar/Views/HistoryDashboardView.swift
@@ -1,9 +1,11 @@
+import PlexModels
import SwiftUI
struct HistoryDashboardView: View {
let settingsStore: PlexSettingsStore
let serverURL: URL?
let historyStore: PlexHistoryStore
+ var allowsMediaNavigation = false
private var clientContext: PlexClientContext {
PlexClientContext(clientIdentifier: settingsStore.clientIdentifier)
@@ -20,7 +22,8 @@ struct HistoryDashboardView: View {
historyWindowLabel: historyStore.historyWindowLabel,
settingsStore: settingsStore,
serverURL: serverURL,
- clientContext: clientContext
+ clientContext: clientContext,
+ allowsMediaNavigation: allowsMediaNavigation
)
}
@@ -41,7 +44,8 @@ struct HistoryDashboardView: View {
settingsStore: settingsStore,
serverURL: serverURL,
clientContext: clientContext,
- accountsByID: historyStore.accountsByID
+ accountsByID: historyStore.accountsByID,
+ allowsMediaNavigation: allowsMediaNavigation
)
}
}
@@ -55,6 +59,7 @@ private struct RecentPlaysCard: View {
let serverURL: URL?
let clientContext: PlexClientContext
let accountsByID: [Int: PlexAccount]
+ let allowsMediaNavigation: Bool
@State private var selectedFilter: PlexHistoryContentFilter = .all
@@ -88,14 +93,20 @@ private struct RecentPlaysCard: View {
} else {
VStack(spacing: 10) {
ForEach(filteredItems) { item in
- RecentHistoryCardView(
- item: item,
- watcherName: item.watcherName(using: accountsByID),
- watcherAccount: item.watcherAccount(using: accountsByID),
- settingsStore: settingsStore,
- serverURL: serverURL,
- clientContext: clientContext
- )
+ HistoryMediaNavigationLink(
+ route: item.mediaRoute,
+ isEnabled: allowsMediaNavigation
+ ) {
+ RecentHistoryCardView(
+ item: item,
+ watcherName: item.watcherName(using: accountsByID),
+ watcherAccount: item.watcherAccount(using: accountsByID),
+ settingsStore: settingsStore,
+ serverURL: serverURL,
+ clientContext: clientContext,
+ showsNavigationIndicator: allowsMediaNavigation && item.mediaRoute != nil
+ )
+ }
}
}
}
@@ -125,6 +136,7 @@ private struct TopChartsCard: View {
let settingsStore: PlexSettingsStore
let serverURL: URL?
let clientContext: PlexClientContext
+ let allowsMediaNavigation: Bool
@State private var selectedFilter: PlexHistoryContentFilter = .all
@@ -164,14 +176,20 @@ private struct TopChartsCard: View {
} else {
VStack(spacing: 10) {
ForEach(Array(entries.enumerated()), id: \.element.id) { index, entry in
- TopChartRow(
- rank: index + 1,
- entry: entry,
- accountsByID: accountsByID,
- settingsStore: settingsStore,
- serverURL: serverURL,
- clientContext: clientContext
- )
+ HistoryMediaNavigationLink(
+ route: entry.mediaRoute,
+ isEnabled: allowsMediaNavigation
+ ) {
+ TopChartRow(
+ rank: index + 1,
+ entry: entry,
+ accountsByID: accountsByID,
+ settingsStore: settingsStore,
+ serverURL: serverURL,
+ clientContext: clientContext,
+ showsNavigationIndicator: allowsMediaNavigation && entry.mediaRoute != nil
+ )
+ }
}
}
}
@@ -203,6 +221,7 @@ private struct TopChartRow: View {
let settingsStore: PlexSettingsStore
let serverURL: URL?
let clientContext: PlexClientContext
+ let showsNavigationIndicator: Bool
private var watcherAccounts: [PlexAccount] {
entry.watcherAccountIDs.compactMap { accountsByID[$0] }
@@ -256,6 +275,13 @@ private struct TopChartRow: View {
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(.white.opacity(0.08), in: Capsule())
+
+ if showsNavigationIndicator {
+ Image(systemName: "chevron.forward")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.tertiary)
+ .accessibilityHidden(true)
+ }
}
}
@@ -360,6 +386,7 @@ private struct RecentHistoryCardView: View {
let settingsStore: PlexSettingsStore
let serverURL: URL?
let clientContext: PlexClientContext
+ let showsNavigationIndicator: Bool
var body: some View {
HStack(alignment: .top, spacing: 12) {
@@ -408,6 +435,13 @@ private struct RecentHistoryCardView: View {
}
Spacer(minLength: 8)
+
+ if showsNavigationIndicator {
+ Image(systemName: "chevron.forward")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.tertiary)
+ .accessibilityHidden(true)
+ }
}
.padding(14)
.background(.white.opacity(0.05), in: RoundedRectangle(cornerRadius: 16, style: .continuous))
@@ -418,7 +452,10 @@ private struct RecentHistoryCardView: View {
return nil
}
- return PlexURLBuilder.mediaURL(serverURL: serverURL, path: item.posterPath)
+ return PlexURLBuilder.mediaURL(
+ serverURL: serverURL,
+ path: item.posterPath(spoilerPolicy: settingsStore.episodeSpoilerPolicy)
+ )
}
private var transcodedPosterURL: URL? {
@@ -428,13 +465,41 @@ private struct RecentHistoryCardView: View {
return PlexURLBuilder.transcodedArtworkURL(
serverURL: serverURL,
- path: item.posterPath,
+ path: item.posterPath(spoilerPolicy: settingsStore.episodeSpoilerPolicy),
width: 116,
height: 160
)
}
}
+private struct HistoryMediaNavigationLink: View {
+ let route: PlexMediaRoute?
+ let isEnabled: Bool
+ @ViewBuilder let content: Content
+
+ init(
+ route: PlexMediaRoute?,
+ isEnabled: Bool,
+ @ViewBuilder content: () -> Content
+ ) {
+ self.route = route
+ self.isEnabled = isEnabled
+ self.content = content()
+ }
+
+ @ViewBuilder
+ var body: some View {
+ if isEnabled, let route {
+ NavigationLink(value: PlexNavigationRoute.media(route)) {
+ content
+ }
+ .buttonStyle(.plain)
+ } else {
+ content
+ }
+ }
+}
+
private struct HistoryWatcherIdentityView: View {
let summary: String?
let accounts: [PlexAccount]
diff --git a/Sources/PlexBar/Views/LibrariesDashboardView.swift b/PlexBar/Views/LibrariesDashboardView.swift
similarity index 99%
rename from Sources/PlexBar/Views/LibrariesDashboardView.swift
rename to PlexBar/Views/LibrariesDashboardView.swift
index 92feadb..b0797e1 100644
--- a/Sources/PlexBar/Views/LibrariesDashboardView.swift
+++ b/PlexBar/Views/LibrariesDashboardView.swift
@@ -1,3 +1,4 @@
+import PlexModels
import SwiftUI
struct LibrariesDashboardView: View {
diff --git a/Sources/PlexBar/Views/MenuBarContentView.swift b/PlexBar/Views/MenuBarContentView.swift
similarity index 78%
rename from Sources/PlexBar/Views/MenuBarContentView.swift
rename to PlexBar/Views/MenuBarContentView.swift
index c79548d..919d299 100644
--- a/Sources/PlexBar/Views/MenuBarContentView.swift
+++ b/PlexBar/Views/MenuBarContentView.swift
@@ -1,3 +1,4 @@
+import PlexModels
import AppKit
import SwiftUI
@@ -8,9 +9,12 @@ struct MenuBarContentView: View {
@Bindable var sessionStore: PlexSessionStore
@Bindable var historyStore: PlexHistoryStore
@Bindable var libraryStore: PlexLibraryStore
+ let playerCoordinator: PlexPlayerCoordinator
@Environment(\.openSettings) private var openSettingsWindow
+ @Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion
@State private var selectedSection: DashboardSection = .streams
@State private var streamContentHeight: CGFloat = 0
+ @State private var activitySummaryHeight: CGFloat = 0
@State private var historyContentHeight: CGFloat = 0
@State private var usersContentHeight: CGFloat = 0
@State private var libraryContentHeight: CGFloat = 0
@@ -18,7 +22,30 @@ struct MenuBarContentView: View {
@State private var terminateMessage = ""
var body: some View {
+ Group {
+ if settingsStore.hasLoadedCredentials {
+ dashboard
+ } else {
+ ProgressView("Loading…")
+ .frame(width: 420, height: 180)
+ }
+ }
+ }
+
+ private var dashboard: some View {
VStack(alignment: .leading, spacing: 16) {
+ if let currentPlayback = playerCoordinator.currentPlayback {
+ PlexMenuBarNowPlayingView(
+ currentPlayback: currentPlayback,
+ selectedServerIdentifier: settingsStore.selectedServerIdentifier,
+ serverURL: connectionStore.resolvedServerURL,
+ serverToken: settingsStore.trimmedServerToken,
+ clientContext: PlexClientContext(
+ clientIdentifier: settingsStore.clientIdentifier
+ )
+ )
+ }
+
sectionPicker
header
content
@@ -26,30 +53,33 @@ struct MenuBarContentView: View {
}
.padding(16)
.frame(width: 420)
- .animation(.snappy(duration: 0.18), value: terminatePrompt?.id)
+ .modifier(PlexActivityVisibility(store: sessionStore, isEnabled: selectedSection == .streams))
+ .animation(
+ accessibilityReduceMotion ? nil : .snappy(duration: 0.18),
+ value: terminatePrompt?.id
+ )
}
+ @ViewBuilder
private var header: some View {
switch selectedSection {
case .streams:
- VStack(alignment: .leading, spacing: 4) {
- HStack(spacing: 10) {
- Text("Active Streams")
- .font(.headline)
-
- Spacer()
-
+ if sessionStore.lastHydratedAt != nil {
+ HStack(alignment: .top, spacing: 8) {
+ PlexActivitySummaryView(
+ summary: sessionStore.activitySummary,
+ isStale: sessionStore.activityErrorMessage != nil
+ )
if sessionStore.isLoading {
ProgressView()
.controlSize(.small)
}
}
-
- Text(subtitle)
- .font(.caption)
- .foregroundStyle(.secondary)
- .lineLimit(2)
- .fixedSize(horizontal: false, vertical: true)
+ .onGeometryChange(for: CGFloat.self) { proxy in
+ proxy.size.height
+ } action: { height in
+ activitySummaryHeight = height
+ }
}
case .history:
VStack(alignment: .leading, spacing: 4) {
@@ -139,7 +169,10 @@ struct MenuBarContentView: View {
}
.padding(4)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
- .animation(.snappy(duration: 0.18), value: selectedSection)
+ .animation(
+ accessibilityReduceMotion ? nil : .snappy(duration: 0.18),
+ value: selectedSection
+ )
}
@ViewBuilder
@@ -167,7 +200,10 @@ struct MenuBarContentView: View {
librariesContent
}
- if let inlineErrorMessage = selectedSection.inlineErrorMessage(
+ if selectedSection == .streams, !sessionStore.sessions.isEmpty,
+ let message = sessionStore.activityErrorMessage {
+ InlineWarningBanner(message: message)
+ } else if let inlineErrorMessage = selectedSection.inlineErrorMessage(
sessionStore: sessionStore,
historyStore: historyStore,
libraryStore: libraryStore
@@ -179,7 +215,7 @@ struct MenuBarContentView: View {
@ViewBuilder
private var streamsContent: some View {
- if let errorMessage = sessionStore.errorMessage, sessionStore.sessions.isEmpty {
+ if let errorMessage = sessionStore.activityErrorMessage ?? sessionStore.errorMessage, sessionStore.sessions.isEmpty {
EmptyStateView(
icon: "exclamationmark.triangle",
title: "Couldn’t Reach Plex",
@@ -188,6 +224,9 @@ struct MenuBarContentView: View {
) {
refreshAllData()
}
+ } else if sessionStore.lastHydratedAt == nil {
+ ProgressView("Loading activity…")
+ .frame(maxWidth: .infinity, minHeight: 132)
} else if sessionStore.sessions.isEmpty {
EmptyStateView(
icon: "popcorn",
@@ -388,15 +427,11 @@ struct MenuBarContentView: View {
switch selectedSection {
case .streams:
- let count = sessionStore.activeStreamCount
- let streamsLabel = count == 1 ? "1 stream" : "\(count) streams"
-
- if let lastUpdated = sessionStore.lastUpdated {
- return "\(streamsLabel) on \(serverLabel) • Updated \(lastUpdated.formatted(date: .omitted, time: .shortened))"
- }
-
- return "\(streamsLabel) on \(serverLabel)"
+ return serverLabel
case .history:
+ guard historyStore.lastUpdated != nil else {
+ return "Watch history on \(serverLabel)"
+ }
let count = historyStore.totalPlayCount
let historyLabel = count == 1
? "1 watch in \(historyStore.historyWindowLabel.lowercased())"
@@ -408,6 +443,9 @@ struct MenuBarContentView: View {
return "\(historyLabel) on \(serverLabel)"
case .users:
+ guard historyStore.lastUpdated != nil else {
+ return "User activity on \(serverLabel)"
+ }
let viewerCount = historyStore.distinctViewerCount
let userLabel = viewerCount == 1
? "1 user in \(historyStore.historyWindowLabel.lowercased())"
@@ -493,7 +531,9 @@ struct MenuBarContentView: View {
return fallbackHeight
}
- let reservedVerticalChrome: CGFloat = 180
+ let summaryHeight = selectedSection == .streams && sessionStore.lastHydratedAt != nil ? activitySummaryHeight : 0
+ let baseChrome: CGFloat = selectedSection == .streams ? 140 : 180
+ let reservedVerticalChrome = baseChrome + (playerCoordinator.currentPlayback == nil ? 0 : 100) + summaryHeight
let screenAwareHeight = max(visibleFrame.height - reservedVerticalChrome, 360)
return min(screenAwareHeight, fallbackHeight)
}
@@ -505,6 +545,89 @@ struct MenuBarContentView: View {
}
}
+private struct PlexMenuBarNowPlayingView: View {
+ @Environment(\.openWindow) private var openWindow
+ let currentPlayback: PlexCurrentPlayback
+ let selectedServerIdentifier: String?
+ let serverURL: URL?
+ let serverToken: String
+ let clientContext: PlexClientContext
+ @ScaledMetric(relativeTo: .body) private var artworkWidth: CGFloat = 42
+
+ private var item: PlexMediaItem {
+ currentPlayback.item
+ }
+
+ private var presentation: PlexPlayerPlaybackInfoPresentation {
+ PlexPlayerPlaybackInfoPresentation(item: item)
+ }
+
+ var body: some View {
+ GroupBox {
+ HStack(alignment: .center, spacing: 12) {
+ PlexArtworkView(
+ primaryImageURL: artworkURL(path: item.posterArtworkPath),
+ fallbackImageURL: artworkURL(
+ path: item.parentThumb ?? item.grandparentThumb ?? item.art
+ ),
+ token: artworkToken,
+ clientContext: clientContext,
+ placeholderSymbol: item.placeholderSymbol,
+ width: artworkWidth,
+ height: artworkHeight,
+ cornerRadius: 6
+ )
+
+ VStack(alignment: .leading, spacing: 3) {
+ Text(presentation.title)
+ .font(.subheadline.weight(.semibold))
+ .lineLimit(1)
+
+ if let hierarchyLine = presentation.hierarchyLine {
+ Text(hierarchyLine)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+
+ PlexMediaFactsView(presentation: item.factsPresentation, badgeFont: .caption2)
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer(minLength: 8)
+
+ Button("Show Player", systemImage: "play.rectangle", action: showPlayer)
+ .controlSize(.small)
+ .accessibilityLabel("Show Player for \(presentation.title)")
+ }
+ .accessibilityElement(children: .contain)
+ } label: {
+ Label("Now Playing", systemImage: "waveform")
+ .font(.caption.weight(.semibold))
+ }
+ }
+
+ private var artworkHeight: CGFloat {
+ item.usesSquareArtwork ? artworkWidth : artworkWidth * 1.4
+ }
+
+ private var artworkToken: String {
+ currentPlayback.belongs(to: selectedServerIdentifier) ? serverToken : ""
+ }
+
+ private func artworkURL(path: String?) -> URL? {
+ guard currentPlayback.belongs(to: selectedServerIdentifier), let serverURL else {
+ return nil
+ }
+ return PlexURLBuilder.mediaURL(serverURL: serverURL, path: path)
+ }
+
+ private func showPlayer() {
+ openWindow(id: PlexMainNavigationStore.windowID)
+ }
+}
+
private enum DashboardSection: String, CaseIterable, Identifiable {
case streams
case history
@@ -659,7 +782,7 @@ private struct EmptyStateView: View {
}
}
-private struct InlineWarningBanner: View {
+struct InlineWarningBanner: View {
let message: String
var body: some View {
diff --git a/Sources/PlexBar/Views/MenuBarLabelView.swift b/PlexBar/Views/MenuBarLabelView.swift
similarity index 100%
rename from Sources/PlexBar/Views/MenuBarLabelView.swift
rename to PlexBar/Views/MenuBarLabelView.swift
diff --git a/PlexBar/Views/PlexActivitySummaryView.swift b/PlexBar/Views/PlexActivitySummaryView.swift
new file mode 100644
index 0000000..a613e0b
--- /dev/null
+++ b/PlexBar/Views/PlexActivitySummaryView.swift
@@ -0,0 +1,108 @@
+import SwiftUI
+
+struct PlexActivitySummaryView: View {
+ let summary: PlexActivitySummary
+ let isStale: Bool
+ @State private var showsBandwidthDetails = false
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ ViewThatFits(in: .horizontal) {
+ HStack(alignment: .firstTextBaseline, spacing: 16) {
+ streamCount
+ Spacer(minLength: 0)
+ bandwidth
+ }
+ VStack(alignment: .leading, spacing: 6) {
+ streamCount
+ bandwidth
+ }
+ }
+
+ if !methodSummary.isEmpty {
+ Text(methodSummary)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ if isStale {
+ Text("Last known activity")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .monospacedDigit()
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ private var streamCount: some View {
+ Text(summary.streamCount == 1 ? "1 active stream" : "\(summary.streamCount.formatted()) active streams")
+ .font(.headline)
+ .fixedSize()
+ }
+
+ @ViewBuilder
+ private var bandwidth: some View {
+ if summary.streamCount > 0 {
+ HStack(alignment: .firstTextBaseline, spacing: 6) {
+ Text(bandwidthSummary)
+ .font(.body)
+ Button("Bandwidth details", systemImage: "info.circle") {
+ showsBandwidthDetails.toggle()
+ }
+ .labelStyle(.iconOnly)
+ .buttonStyle(.plain)
+ .foregroundStyle(.secondary)
+ .help("View bandwidth and local/remote totals")
+ .popover(isPresented: $showsBandwidthDetails) {
+ bandwidthDetails
+ }
+ }
+ .fixedSize()
+ }
+ }
+
+ private var methodSummary: String {
+ var pieces: [String] = []
+ if summary.directPlayCount > 0 { pieces.append("\(summary.directPlayCount.formatted()) direct play") }
+ if summary.directStreamCount > 0 { pieces.append("\(summary.directStreamCount.formatted()) direct stream") }
+ if summary.transcodingCount > 0 { pieces.append("\(summary.transcodingCount.formatted()) transcoding") }
+ if summary.unknownCount > 0 { pieces.append("\(summary.unknownCount.formatted()) unknown") }
+ return pieces.joined(separator: " · ")
+ }
+
+ private var bandwidthSummary: String {
+ guard summary.reportedBandwidthCount > 0 else { return "Unavailable" }
+ return PlexActivitySummary.bandwidthText(kbps: summary.totalBandwidthKbps)
+ }
+
+ private var bandwidthDetails: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("Stream bandwidth")
+ .font(.headline)
+ if summary.reportedBandwidthCount > 0 {
+ Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 8) {
+ bandwidthRow(summary.hasPartialBandwidth ? "Known subtotal" : "Total", value: summary.totalBandwidthKbps)
+ bandwidthRow("Local", value: summary.localBandwidthKbps)
+ bandwidthRow("Remote", value: summary.remoteBandwidthKbps)
+ if summary.unknownLocationCount > 0 {
+ bandwidthRow("Unknown location", value: summary.unknownLocationBandwidthKbps)
+ }
+ }
+ }
+ }
+ .fixedSize()
+ .padding(16)
+ }
+
+ private func bandwidthRow(_ title: String, value: Double) -> some View {
+ GridRow {
+ Text(title).foregroundStyle(.secondary)
+ Text(PlexActivitySummary.bandwidthText(kbps: value))
+ .monospacedDigit()
+ .gridColumnAlignment(.trailing)
+ }
+ .accessibilityElement(children: .combine)
+ }
+}
diff --git a/PlexBar/Views/PlexActivityVisibility.swift b/PlexBar/Views/PlexActivityVisibility.swift
new file mode 100644
index 0000000..0b88bfd
--- /dev/null
+++ b/PlexBar/Views/PlexActivityVisibility.swift
@@ -0,0 +1,112 @@
+import AppKit
+import SwiftUI
+
+struct PlexActivityVisibility: ViewModifier {
+ let store: PlexSessionStore
+ var isEnabled = true
+ @State private var consumer = UUID()
+ @State private var isPresented = false
+
+ func body(content: Content) -> some View {
+ content
+ .background {
+ ActivityWindowVisibility(isEnabled: isEnabled && isPresented) { visible in
+ store.setActivityVisible(visible, consumer: consumer)
+ }
+ .frame(width: 0, height: 0)
+ }
+ .onAppear { isPresented = true }
+ .onDisappear {
+ isPresented = false
+ store.setActivityVisible(false, consumer: consumer)
+ }
+ }
+}
+
+private struct ActivityWindowVisibility: NSViewRepresentable {
+ let isEnabled: Bool
+ let onChange: @MainActor (Bool) -> Void
+
+ func makeNSView(context: Context) -> PlexActivityVisibilityView {
+ PlexActivityVisibilityView()
+ }
+
+ func updateNSView(_ view: PlexActivityVisibilityView, context: Context) {
+ view.isEnabled = isEnabled
+ view.onChange = onChange
+ view.scheduleVisibilityUpdate()
+ }
+
+ static func dismantleNSView(_ view: PlexActivityVisibilityView, coordinator: ()) {
+ view.stopObserving()
+ }
+}
+
+// SwiftUI appearance alone doesn't describe a closed menu panel, a minimized
+// window, or a window covered by another app. Observe the actual hosting window.
+final class PlexActivityVisibilityView: NSView {
+ var isEnabled = false
+ var onChange: (@MainActor (Bool) -> Void)?
+ private var observers: [NSObjectProtocol] = []
+ private var updateTask: Task?
+ private var lastVisibility = false
+
+ override func viewDidMoveToWindow() {
+ super.viewDidMoveToWindow()
+ stopObserving()
+ guard let window else { return }
+ let center = NotificationCenter.default
+ for name in [NSWindow.didChangeOcclusionStateNotification,
+ NSWindow.didMiniaturizeNotification,
+ NSWindow.didDeminiaturizeNotification,
+ NSWindow.willCloseNotification] {
+ observers.append(center.addObserver(forName: name, object: window, queue: .main) { [weak self] _ in
+ MainActor.assumeIsolated { self?.scheduleVisibilityUpdate() }
+ })
+ }
+ for name in [NSApplication.didHideNotification, NSApplication.didUnhideNotification] {
+ observers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
+ MainActor.assumeIsolated { self?.scheduleVisibilityUpdate() }
+ })
+ }
+ scheduleVisibilityUpdate()
+ }
+
+ override func viewDidHide() {
+ super.viewDidHide()
+ scheduleVisibilityUpdate()
+ }
+
+ override func viewDidUnhide() {
+ super.viewDidUnhide()
+ scheduleVisibilityUpdate()
+ }
+
+ func scheduleVisibilityUpdate() {
+ updateTask?.cancel()
+ // Deliver after the representable update, never mutate observed SwiftUI
+ // state from inside updateNSView. Read current visibility at delivery time.
+ updateTask = Task { @MainActor [weak self] in
+ guard !Task.isCancelled, let self else { return }
+ let visible = self.isEnabled && !self.isHiddenOrHasHiddenAncestor
+ && self.window?.isVisible == true
+ && self.window?.isMiniaturized == false
+ && self.window?.occlusionState.contains(.visible) == true
+ self.publish(visible)
+ }
+ }
+
+ func stopObserving() {
+ updateTask?.cancel()
+ updateTask = nil
+ observers.forEach(NotificationCenter.default.removeObserver)
+ observers.removeAll()
+ publish(false)
+ }
+
+ private func publish(_ visible: Bool) {
+ guard visible != lastVisibility else { return }
+ lastVisibility = visible
+ onChange?(visible)
+ }
+}
diff --git a/PlexBar/Views/PlexArtworkBackdrop.swift b/PlexBar/Views/PlexArtworkBackdrop.swift
new file mode 100644
index 0000000..98946c8
--- /dev/null
+++ b/PlexBar/Views/PlexArtworkBackdrop.swift
@@ -0,0 +1,127 @@
+import AppKit
+import SwiftUI
+
+struct PlexArtworkBackdrop: View {
+ let primaryImageURL: URL?
+ let fallbackImageURL: URL?
+ let token: String
+ let clientContext: PlexClientContext
+ @Environment(\.colorScheme) private var colorScheme
+ @Environment(\.colorSchemeContrast) private var colorSchemeContrast
+ @Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion
+ @State private var artwork: PlexArtworkPresentationState
+
+ init(
+ primaryImageURL: URL?,
+ fallbackImageURL: URL? = nil,
+ token: String,
+ clientContext: PlexClientContext
+ ) {
+ self.primaryImageURL = primaryImageURL
+ self.fallbackImageURL = fallbackImageURL
+ self.token = token
+ self.clientContext = clientContext
+ _artwork = State(initialValue: PlexArtworkPresentationState(
+ primaryImageURL: primaryImageURL,
+ fallbackImageURL: fallbackImageURL,
+ token: token,
+ wantsPalette: true,
+ maximumPixelSize: 160
+ ))
+ }
+
+ var body: some View {
+ ZStack {
+ windowBackground
+
+ if let palette = artwork.palette {
+ MeshGradient(
+ width: 2,
+ height: 2,
+ points: [
+ SIMD2(0, 0),
+ SIMD2(1, 0),
+ SIMD2(0, 1),
+ SIMD2(1, 1),
+ ],
+ colors: palette.swiftUIColors
+ )
+ .opacity(style.paletteOpacity)
+
+ LinearGradient(
+ colors: [
+ windowBackground.opacity(style.topFadeOpacity),
+ windowBackground.opacity(style.middleFadeOpacity),
+ windowBackground.opacity(style.bottomFadeOpacity),
+ ],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ }
+ }
+ .animation(
+ accessibilityReduceMotion ? nil : .easeOut(duration: 0.28),
+ value: artwork.palette
+ )
+ .task(id: requestKey) {
+ await artwork.load(
+ primaryImageURL: primaryImageURL,
+ fallbackImageURL: fallbackImageURL,
+ token: token,
+ clientContext: clientContext,
+ wantsPalette: true,
+ maximumPixelSize: 160
+ )
+ }
+ .accessibilityHidden(true)
+ .allowsHitTesting(false)
+ }
+
+ private var windowBackground: Color {
+ Color(nsColor: .windowBackgroundColor)
+ }
+
+ private var style: PlexArtworkBackdropStyle {
+ PlexArtworkBackdropStyle(
+ colorScheme: colorScheme,
+ contrast: colorSchemeContrast
+ )
+ }
+
+ private var requestKey: String {
+ [
+ primaryImageURL?.absoluteString,
+ fallbackImageURL?.absoluteString,
+ clientContext.clientIdentifier,
+ token,
+ ]
+ .compactMap { $0 }
+ .joined(separator: "|")
+ }
+}
+
+struct PlexArtworkBackdropStyle: Equatable {
+ let paletteOpacity: Double
+ let topFadeOpacity: Double
+ let middleFadeOpacity: Double
+ let bottomFadeOpacity: Double
+
+ init(colorScheme: ColorScheme, contrast: ColorSchemeContrast) {
+ let usesIncreasedContrast = contrast == .increased
+ switch colorScheme {
+ case .dark:
+ paletteOpacity = usesIncreasedContrast ? 0.50 : 0.72
+ topFadeOpacity = usesIncreasedContrast ? 0.34 : 0.10
+ middleFadeOpacity = usesIncreasedContrast ? 0.70 : 0.50
+ case .light:
+ paletteOpacity = usesIncreasedContrast ? 0.20 : 0.34
+ topFadeOpacity = usesIncreasedContrast ? 0.46 : 0.24
+ middleFadeOpacity = usesIncreasedContrast ? 0.80 : 0.66
+ @unknown default:
+ paletteOpacity = usesIncreasedContrast ? 0.20 : 0.34
+ topFadeOpacity = usesIncreasedContrast ? 0.46 : 0.24
+ middleFadeOpacity = usesIncreasedContrast ? 0.80 : 0.66
+ }
+ bottomFadeOpacity = usesIncreasedContrast ? 1.0 : 0.96
+ }
+}
diff --git a/Sources/PlexBar/Views/PlexArtworkView.swift b/PlexBar/Views/PlexArtworkView.swift
similarity index 74%
rename from Sources/PlexBar/Views/PlexArtworkView.swift
rename to PlexBar/Views/PlexArtworkView.swift
index 5197198..6cefd73 100644
--- a/Sources/PlexBar/Views/PlexArtworkView.swift
+++ b/PlexBar/Views/PlexArtworkView.swift
@@ -9,6 +9,7 @@ struct PlexArtworkView: View {
let width: CGFloat
let height: CGFloat
var cornerRadius: CGFloat = 12
+ var contentMode: ContentMode = .fill
@State private var artwork: PlexArtworkPresentationState
init(
@@ -19,7 +20,8 @@ struct PlexArtworkView: View {
placeholderSymbol: String,
width: CGFloat,
height: CGFloat,
- cornerRadius: CGFloat = 12
+ cornerRadius: CGFloat = 12,
+ contentMode: ContentMode = .fill
) {
self.primaryImageURL = primaryImageURL
self.fallbackImageURL = fallbackImageURL
@@ -29,20 +31,21 @@ struct PlexArtworkView: View {
self.width = width
self.height = height
self.cornerRadius = cornerRadius
+ self.contentMode = contentMode
+ let maximumPixelSize = Int(ceil(max(width, height) * 2))
_artwork = State(initialValue: PlexArtworkPresentationState(
primaryImageURL: primaryImageURL,
fallbackImageURL: fallbackImageURL,
token: token,
- wantsPalette: false
+ wantsPalette: false,
+ maximumPixelSize: maximumPixelSize
))
}
var body: some View {
ZStack {
if let image = artwork.image {
- image
- .resizable()
- .scaledToFill()
+ artworkContent(image)
} else {
placeholder
.overlay {
@@ -54,6 +57,7 @@ struct PlexArtworkView: View {
}
}
.frame(width: width, height: height)
+ .compositingGroup()
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.task(id: requestKey) {
await artwork.load(
@@ -61,22 +65,42 @@ struct PlexArtworkView: View {
fallbackImageURL: fallbackImageURL,
token: token,
clientContext: clientContext,
- wantsPalette: false
+ wantsPalette: false,
+ maximumPixelSize: maximumPixelSize
)
}
}
+ @ViewBuilder
+ private func artworkContent(_ image: Image) -> some View {
+ switch contentMode {
+ case .fit:
+ image
+ .resizable()
+ .scaledToFit()
+ case .fill:
+ image
+ .resizable()
+ .scaledToFill()
+ }
+ }
+
private var requestKey: String {
[
primaryImageURL?.absoluteString,
fallbackImageURL?.absoluteString,
clientContext.clientIdentifier,
token,
+ String(maximumPixelSize),
]
.compactMap { $0 }
.joined(separator: "|")
}
+ private var maximumPixelSize: Int {
+ Int(ceil(max(width, height) * 2))
+ }
+
private var placeholder: some View {
ZStack {
LinearGradient(
diff --git a/PlexBar/Views/PlexAutomaticDownloadSheet.swift b/PlexBar/Views/PlexAutomaticDownloadSheet.swift
new file mode 100644
index 0000000..32109d6
--- /dev/null
+++ b/PlexBar/Views/PlexAutomaticDownloadSheet.swift
@@ -0,0 +1,87 @@
+import PlexModels
+import SwiftUI
+
+struct PlexAutomaticDownloadSheet: View {
+ @Environment(\.dismiss) private var dismiss
+ let item: PlexMediaItem
+ @Bindable var downloadsStore: PlexDownloadsStore
+ @State private var policy: PlexAutomaticDownloadPolicy = .allEpisodes
+ @State private var keepsUpToDate = true
+ @State private var removesWatchedDownloads = false
+ @State private var isCreating = false
+ @State private var errorMessage: String?
+
+ var body: some View {
+ VStack(spacing: 0) {
+ Form {
+ Picker("Episodes", selection: $policy) {
+ ForEach(PlexAutomaticDownloadPolicy.allCases) { option in
+ Text(option.title).tag(option)
+ }
+ }
+ .pickerStyle(.menu)
+
+ Toggle("Download New Episodes", isOn: $keepsUpToDate)
+ Toggle("Remove Downloads After Watching", isOn: $removesWatchedDownloads)
+
+ Section("Media") {
+ Text("Uses the quality and subtitle choices in Downloads settings.")
+ .foregroundStyle(.secondary)
+ }
+ }
+ .formStyle(.grouped)
+ .disabled(isCreating)
+ .overlay {
+ if isCreating {
+ ProgressView("Preparing Downloads…")
+ .padding(20)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
+ }
+ }
+
+ Divider()
+
+ HStack {
+ Button("Cancel", role: .cancel) { dismiss() }
+ .keyboardShortcut(.cancelAction)
+ Spacer()
+ Button("Download", action: create)
+ .keyboardShortcut(.defaultAction)
+ .disabled(isCreating)
+ }
+ .padding()
+ }
+ .frame(width: 520, height: 390)
+ .navigationTitle("Download \(item.title)")
+ .alert(
+ "Download Error",
+ isPresented: Binding(
+ get: { errorMessage != nil },
+ set: { if !$0 { errorMessage = nil } }
+ )
+ ) {
+ Button("OK") {}
+ } message: {
+ Text(errorMessage ?? "Unknown download error.")
+ }
+ }
+
+ private func create() {
+ isCreating = true
+ errorMessage = nil
+ Task {
+ do {
+ try await downloadsStore.createAutomaticDownloadRule(
+ for: item,
+ policy: policy,
+ keepsUpToDate: keepsUpToDate,
+ removesWatchedDownloads: removesWatchedDownloads
+ )
+ dismiss()
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ isCreating = false
+ }
+ }
+}
diff --git a/Sources/PlexBar/Views/PlexAvatarView.swift b/PlexBar/Views/PlexAvatarView.swift
similarity index 77%
rename from Sources/PlexBar/Views/PlexAvatarView.swift
rename to PlexBar/Views/PlexAvatarView.swift
index e995062..b09873a 100644
--- a/Sources/PlexBar/Views/PlexAvatarView.swift
+++ b/PlexBar/Views/PlexAvatarView.swift
@@ -1,3 +1,4 @@
+import PlexModels
import AppKit
import SwiftUI
@@ -28,6 +29,7 @@ struct PlexAvatarView: View {
self.clientContext = clientContext
self.size = size
self.imageClient = imageClient
+ let maximumPixelSize = Int(ceil(size * 2))
let resolvedRequest = Self.resolveRequest(
thumb: thumb,
@@ -35,7 +37,13 @@ struct PlexAvatarView: View {
serverToken: serverToken,
userToken: userToken
)
- let cachedImage = resolvedRequest.map { imageClient.cachedImage(from: [$0.url], token: $0.token) } ?? nil
+ let cachedImage = resolvedRequest.map {
+ imageClient.cachedImage(
+ from: [$0.url],
+ token: $0.token,
+ maximumPixelSize: maximumPixelSize
+ )
+ } ?? nil
_image = State(initialValue: cachedImage.map(Image.init(nsImage:)))
}
@@ -88,7 +96,11 @@ struct PlexAvatarView: View {
return
}
- if let cachedImage = imageClient.cachedImage(from: [resolvedRequest.url], token: resolvedRequest.token) {
+ if let cachedImage = imageClient.cachedImage(
+ from: [resolvedRequest.url],
+ token: resolvedRequest.token,
+ maximumPixelSize: maximumPixelSize
+ ) {
image = Image(nsImage: cachedImage)
return
}
@@ -102,7 +114,8 @@ struct PlexAvatarView: View {
if let loadedImage = await imageClient.fetchImage(
from: [resolvedRequest.url],
token: resolvedRequest.token,
- clientContext: clientContext
+ clientContext: clientContext,
+ maximumPixelSize: maximumPixelSize
) {
image = Image(nsImage: loadedImage)
} else {
@@ -110,12 +123,16 @@ struct PlexAvatarView: View {
}
}
- private struct AvatarRequest {
+ private var maximumPixelSize: Int {
+ Int(ceil(size * 2))
+ }
+
+ struct AvatarRequest {
let url: URL
let token: String
}
- private static func resolveRequest(
+ static func resolveRequest(
thumb: String?,
serverURL: URL?,
serverToken: String,
@@ -138,7 +155,14 @@ struct PlexAvatarView: View {
return nil
}
- let token = PlexRemoteService.isPlexHosted(imageURL) ? userToken : serverToken
+ let token: String
+ if PlexRemoteService.isPlexHosted(imageURL) {
+ token = userToken
+ } else if let serverURL, PlexImageRequest.hasSameOrigin(imageURL, serverURL) {
+ token = serverToken
+ } else {
+ token = ""
+ }
return AvatarRequest(url: imageURL, token: token)
}
}
diff --git a/PlexBar/Views/PlexCastAndCrewView.swift b/PlexBar/Views/PlexCastAndCrewView.swift
new file mode 100644
index 0000000..2649aa8
--- /dev/null
+++ b/PlexBar/Views/PlexCastAndCrewView.swift
@@ -0,0 +1,113 @@
+import PlexModels
+import SwiftUI
+
+struct PlexCastAndCrewView: View {
+ let item: PlexMediaItem
+ let episodeSeriesCast: [PlexTag]
+ @Bindable var settingsStore: PlexSettingsStore
+ @Bindable var connectionStore: PlexConnectionStore
+ @ScaledMetric(relativeTo: .subheadline) private var portraitSize: CGFloat = 88
+
+ init(
+ item: PlexMediaItem,
+ episodeSeriesCast: [PlexTag] = [],
+ settingsStore: PlexSettingsStore,
+ connectionStore: PlexConnectionStore
+ ) {
+ self.item = item
+ self.episodeSeriesCast = episodeSeriesCast
+ self.settingsStore = settingsStore
+ self.connectionStore = connectionStore
+ }
+
+ private var presentation: PlexCastAndCrewPresentation {
+ PlexCastAndCrewPresentation(
+ item: item,
+ episodeSeriesCast: episodeSeriesCast
+ )
+ }
+
+ var body: some View {
+ if !presentation.isEmpty {
+ VStack(alignment: .leading, spacing: 22) {
+ if !presentation.cast.isEmpty {
+ creditShelf(title: "Cast", credits: presentation.cast)
+ }
+
+ if !presentation.crew.isEmpty {
+ creditShelf(title: "Crew", credits: presentation.crew)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .accessibilityElement(children: .contain)
+ }
+ }
+
+ private func creditShelf(
+ title: String,
+ credits: [PlexCastAndCrewCredit]
+ ) -> some View {
+ VStack(alignment: .leading, spacing: 10) {
+ Text(title)
+ .font(.title3.weight(.semibold))
+ .accessibilityAddTraits(.isHeader)
+
+ ScrollView(.horizontal) {
+ LazyHStack(alignment: .top, spacing: 14) {
+ ForEach(credits) { credit in
+ if let route = credit.route {
+ NavigationLink(value: PlexNavigationRoute.person(route)) {
+ creditCard(credit)
+ }
+ .buttonStyle(.plain)
+ } else {
+ creditCard(credit)
+ }
+ }
+ }
+ }
+ .scrollIndicators(.hidden)
+ }
+ .accessibilityElement(children: .contain)
+ .accessibilityLabel(title)
+ }
+
+ private func creditCard(_ credit: PlexCastAndCrewCredit) -> some View {
+ let request = imageRequest(for: credit)
+ return VStack(alignment: .leading, spacing: 5) {
+ PlexArtworkView(
+ primaryImageURL: request?.url,
+ fallbackImageURL: nil,
+ token: request?.token ?? "",
+ clientContext: PlexClientContext(clientIdentifier: settingsStore.clientIdentifier),
+ placeholderSymbol: "person.crop.square",
+ width: portraitSize,
+ height: portraitSize,
+ cornerRadius: 12
+ )
+
+ Text(credit.name)
+ .font(.subheadline.weight(.semibold))
+ .lineLimit(2)
+
+ Text(credit.subtitle)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+ .frame(width: portraitSize, alignment: .leading)
+ .contentShape(Rectangle())
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(credit.name)
+ .accessibilityValue(credit.subtitle)
+ .accessibilityAddTraits(credit.route == nil ? [] : .isButton)
+ }
+
+ private func imageRequest(for credit: PlexCastAndCrewCredit) -> PlexImageRequest? {
+ PlexImageRequest(
+ path: credit.thumb,
+ serverURL: connectionStore.resolvedServerURL,
+ serverToken: settingsStore.trimmedServerToken
+ )
+ }
+}
diff --git a/PlexBar/Views/PlexCinematicBackdrop.swift b/PlexBar/Views/PlexCinematicBackdrop.swift
new file mode 100644
index 0000000..5df858c
--- /dev/null
+++ b/PlexBar/Views/PlexCinematicBackdrop.swift
@@ -0,0 +1,114 @@
+import SwiftUI
+
+/// The shared cinematic treatment for Mac and Apple TV media pages.
+/// Image loading belongs to the caller; this view owns only presentation.
+struct PlexCinematicBackdrop: View {
+ let image: Image?
+ let pageBackground: Color
+ var contentPlacement: ContentPlacement = .bottom
+
+ enum ContentPlacement {
+ case bottom
+ case leading
+ }
+ @Environment(\.colorSchemeContrast) private var contrast
+
+ var body: some View {
+ ZStack {
+ pageBackground
+
+ if let image {
+ fittedImage(image)
+
+ if contentPlacement == .bottom {
+ fittedImage(image)
+ .blur(radius: 18, opaque: true)
+ .saturation(style.blurSaturation)
+ .overlay(Color.black.opacity(style.blurDarkeningOpacity))
+ .mask {
+ LinearGradient(
+ stops: [
+ .init(color: .clear, location: 0.34),
+ .init(color: .white.opacity(0.18), location: 0.44),
+ .init(color: .white.opacity(0.72), location: 0.58),
+ .init(color: .white, location: 0.70),
+ ],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ }
+ }
+ }
+
+ if contentPlacement == .leading {
+ LinearGradient(
+ stops: [
+ .init(color: .black.opacity(contrast == .increased ? 0.88 : 0.72), location: 0),
+ .init(color: .black.opacity(contrast == .increased ? 0.80 : 0.62), location: 0.36),
+ .init(color: .black.opacity(0.16), location: 0.67),
+ .init(color: .clear, location: 1),
+ ],
+ startPoint: .leading,
+ endPoint: .trailing
+ )
+ } else {
+ LinearGradient(
+ stops: [
+ .init(color: .clear, location: 0.20),
+ .init(color: .black.opacity(style.upperScrimOpacity), location: 0.40),
+ .init(color: .black.opacity(style.contentScrimOpacity), location: 0.62),
+ .init(color: .black.opacity(style.lowerScrimOpacity), location: 0.78),
+ ],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ }
+
+ LinearGradient(
+ stops: [
+ .init(color: .clear, location: 0.36),
+ .init(color: pageBackground.opacity(0.08), location: 0.50),
+ .init(color: pageBackground.opacity(0.34), location: 0.66),
+ .init(color: pageBackground.opacity(0.74), location: 0.82),
+ .init(color: pageBackground, location: 0.96),
+ ],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ }
+ .clipped()
+ .allowsHitTesting(false)
+ .accessibilityHidden(true)
+ }
+
+ private func fittedImage(_ image: Image) -> some View {
+ GeometryReader { geometry in
+ image
+ .resizable()
+ .scaledToFill()
+ .frame(width: geometry.size.width, height: geometry.size.height)
+ .clipped()
+ }
+ }
+
+ private var style: PlexCinematicHeroStyle {
+ PlexCinematicHeroStyle(contrast: contrast)
+ }
+}
+
+struct PlexCinematicHeroStyle: Equatable {
+ let blurSaturation: Double
+ let blurDarkeningOpacity: Double
+ let upperScrimOpacity: Double
+ let contentScrimOpacity: Double
+ let lowerScrimOpacity: Double
+
+ init(contrast: ColorSchemeContrast) {
+ let usesIncreasedContrast = contrast == .increased
+ blurSaturation = usesIncreasedContrast ? 0.56 : 0.78
+ blurDarkeningOpacity = usesIncreasedContrast ? 0.64 : 0.48
+ upperScrimOpacity = usesIncreasedContrast ? 0.28 : 0.16
+ contentScrimOpacity = usesIncreasedContrast ? 0.72 : 0.56
+ lowerScrimOpacity = usesIncreasedContrast ? 0.56 : 0.40
+ }
+}
diff --git a/PlexBar/Views/PlexCinematicHero.swift b/PlexBar/Views/PlexCinematicHero.swift
new file mode 100644
index 0000000..f5310a5
--- /dev/null
+++ b/PlexBar/Views/PlexCinematicHero.swift
@@ -0,0 +1,150 @@
+import SwiftUI
+
+struct PlexCinematicHero: View {
+ let primaryImageURL: URL?
+ let fallbackImageURL: URL?
+ let token: String
+ let clientContext: PlexClientContext
+ let placeholderSymbol: String
+ private let content: Content
+ private let pageBackground = Color(nsColor: .windowBackgroundColor)
+ @Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion
+ @State private var artwork: PlexArtworkPresentationState
+
+ init(
+ primaryImageURL: URL?,
+ fallbackImageURL: URL? = nil,
+ token: String,
+ clientContext: PlexClientContext,
+ placeholderSymbol: String,
+ @ViewBuilder content: () -> Content
+ ) {
+ self.primaryImageURL = primaryImageURL
+ self.fallbackImageURL = fallbackImageURL
+ self.token = token
+ self.clientContext = clientContext
+ self.placeholderSymbol = placeholderSymbol
+ self.content = content()
+ _artwork = State(initialValue: PlexArtworkPresentationState(
+ primaryImageURL: primaryImageURL,
+ fallbackImageURL: fallbackImageURL,
+ token: token,
+ wantsPalette: false,
+ maximumPixelSize: 2_400
+ ))
+ }
+
+ var body: some View {
+ ZStack(alignment: .bottomLeading) {
+ PlexCinematicBackdrop(image: artwork.image, pageBackground: pageBackground)
+
+ if artwork.image == nil {
+ placeholder
+ }
+
+ content
+ .padding(.horizontal, 32)
+ .padding(.bottom, 44)
+ }
+ .frame(maxWidth: .infinity)
+ .containerRelativeFrame(.vertical, alignment: .top) { availableHeight, _ in
+ min(
+ max(
+ availableHeight * PlexCinematicHeroMetrics.viewportHeightFraction,
+ PlexCinematicHeroMetrics.minimumHeight
+ ),
+ PlexCinematicHeroMetrics.maximumHeight
+ )
+ }
+ .animation(
+ accessibilityReduceMotion ? nil : .easeOut(duration: 0.24),
+ value: artwork.cgImage != nil
+ )
+ .task(id: requestKey) {
+ await artwork.load(
+ primaryImageURL: primaryImageURL,
+ fallbackImageURL: fallbackImageURL,
+ token: token,
+ clientContext: clientContext,
+ wantsPalette: false,
+ maximumPixelSize: 2_400
+ )
+ }
+ .accessibilityElement(children: .contain)
+ }
+
+ private var placeholder: some View {
+ ZStack {
+ LinearGradient(
+ colors: [
+ Color.black.opacity(0.76),
+ Color.black.opacity(0.18),
+ Color.clear,
+ ],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+
+ Image(systemName: placeholderSymbol)
+ .font(.system(size: 76, weight: .ultraLight))
+ .foregroundStyle(.white.opacity(0.24))
+
+ if artwork.isLoading {
+ ProgressView()
+ .controlSize(.small)
+ .tint(.white)
+ .offset(y: 62)
+ }
+ }
+ .accessibilityHidden(true)
+ }
+
+ private var requestKey: String {
+ [
+ primaryImageURL?.absoluteString,
+ fallbackImageURL?.absoluteString,
+ clientContext.clientIdentifier,
+ token,
+ ]
+ .compactMap { $0 }
+ .joined(separator: "|")
+ }
+
+}
+
+private struct PlexCinematicPrimaryButtonStyle: ButtonStyle {
+ func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .contentShape(.capsule)
+ }
+}
+
+private struct PlexCinematicUtilityButtonStyle: ButtonStyle {
+ func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .contentShape(.circle)
+ }
+}
+
+extension View {
+ func plexCinematicPrimaryButton() -> some View {
+ buttonStyle(PlexCinematicPrimaryButtonStyle())
+ .frame(height: 44)
+ .contentShape(Capsule())
+ .glassEffect(
+ .regular.tint(.white.opacity(0.62)).interactive(),
+ in: .capsule
+ )
+ .foregroundStyle(.black.opacity(0.86))
+ }
+
+ func plexCinematicUtilityButton() -> some View {
+ buttonStyle(PlexCinematicUtilityButtonStyle())
+ .font(.system(size: 16, weight: .medium))
+ .frame(width: 44, height: 44)
+ .contentShape(Circle())
+ .glassEffect(.regular.interactive(), in: .circle)
+ }
+}
diff --git a/PlexBar/Views/PlexCinematicHeroLayout.swift b/PlexBar/Views/PlexCinematicHeroLayout.swift
new file mode 100644
index 0000000..3a0a7d8
--- /dev/null
+++ b/PlexBar/Views/PlexCinematicHeroLayout.swift
@@ -0,0 +1,145 @@
+import PlexModels
+import SwiftUI
+
+enum PlexCinematicHeroMetrics {
+ static let viewportHeightFraction: CGFloat = 0.68
+ static let minimumHeight: CGFloat = 600
+ static let maximumHeight: CGFloat = 780
+ static let primaryColumnWidth: CGFloat = 320
+ static let columnSpacing: CGFloat = 32
+ static let secondaryColumnMaximumWidth: CGFloat = 590
+ static let logoHeight: CGFloat = 98
+}
+
+struct PlexCinematicHeroLayout: View {
+ private let primary: Primary
+ private let secondary: Secondary
+
+ init(
+ @ViewBuilder primary: () -> Primary,
+ @ViewBuilder secondary: () -> Secondary
+ ) {
+ self.primary = primary()
+ self.secondary = secondary()
+ }
+
+ var body: some View {
+ HStack(alignment: .bottom, spacing: PlexCinematicHeroMetrics.columnSpacing) {
+ primary
+ .frame(
+ width: PlexCinematicHeroMetrics.primaryColumnWidth,
+ alignment: .leading
+ )
+
+ secondary
+ .frame(
+ maxWidth: PlexCinematicHeroMetrics.secondaryColumnMaximumWidth,
+ alignment: .leading
+ )
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
+
+struct PlexCinematicMediaHero: View {
+ let primaryImageURL: URL?
+ let fallbackImageURL: URL?
+ let clearLogoURL: URL?
+ let title: String
+ let logoAccessibilityLabel: String?
+ let token: String
+ let clientContext: PlexClientContext
+ let placeholderSymbol: String
+ let playbackTitle: String?
+ let ratingsItem: PlexMediaItem
+ let hasResumePosition: Bool
+ let resumeProgress: Double?
+ let isPreparingPlayback: Bool
+ let isPlaybackEnabled: Bool
+ let preparePlayback: (PlexPlaybackStartOption) -> Void
+ private let actions: Actions
+ private let details: Details
+
+ init(
+ primaryImageURL: URL?,
+ fallbackImageURL: URL?,
+ clearLogoURL: URL?,
+ title: String,
+ logoAccessibilityLabel: String? = nil,
+ token: String,
+ clientContext: PlexClientContext,
+ placeholderSymbol: String,
+ playbackTitle: String?,
+ ratingsItem: PlexMediaItem,
+ hasResumePosition: Bool,
+ resumeProgress: Double?,
+ isPreparingPlayback: Bool,
+ isPlaybackEnabled: Bool,
+ preparePlayback: @escaping (PlexPlaybackStartOption) -> Void,
+ @ViewBuilder actions: () -> Actions,
+ @ViewBuilder details: () -> Details
+ ) {
+ self.primaryImageURL = primaryImageURL
+ self.fallbackImageURL = fallbackImageURL
+ self.clearLogoURL = clearLogoURL
+ self.title = title
+ self.logoAccessibilityLabel = logoAccessibilityLabel
+ self.token = token
+ self.clientContext = clientContext
+ self.placeholderSymbol = placeholderSymbol
+ self.playbackTitle = playbackTitle
+ self.ratingsItem = ratingsItem
+ self.hasResumePosition = hasResumePosition
+ self.resumeProgress = resumeProgress
+ self.isPreparingPlayback = isPreparingPlayback
+ self.isPlaybackEnabled = isPlaybackEnabled
+ self.preparePlayback = preparePlayback
+ self.actions = actions()
+ self.details = details()
+ }
+
+ var body: some View {
+ PlexCinematicHero(
+ primaryImageURL: primaryImageURL,
+ fallbackImageURL: fallbackImageURL,
+ token: token,
+ clientContext: clientContext,
+ placeholderSymbol: placeholderSymbol
+ ) {
+ PlexCinematicHeroLayout {
+ VStack(alignment: .leading, spacing: 18) {
+ PlexMediaLogoView(
+ imageURL: clearLogoURL,
+ fallbackTitle: title,
+ accessibilityLabel: logoAccessibilityLabel,
+ token: token,
+ clientContext: clientContext,
+ maximumWidth: PlexCinematicHeroMetrics.primaryColumnWidth,
+ maximumHeight: PlexCinematicHeroMetrics.logoHeight
+ )
+ .accessibilityAddTraits(.isHeader)
+
+ if let playbackTitle {
+ PlexPlaybackStartControl(
+ title: playbackTitle,
+ hasResumePosition: hasResumePosition,
+ resumeProgress: resumeProgress,
+ isPreparing: isPreparingPlayback,
+ isEnabled: isPlaybackEnabled,
+ width: PlexCinematicHeroMetrics.primaryColumnWidth,
+ action: preparePlayback
+ )
+ }
+
+ actions
+ }
+ } secondary: {
+ VStack(alignment: .leading, spacing: 12) {
+ details
+ PlexExternalRatingsView(item: ratingsItem)
+ }
+ }
+ }
+ .foregroundStyle(.white)
+ }
+}
diff --git a/PlexBar/Views/PlexCollectionAndPlaylistViews.swift b/PlexBar/Views/PlexCollectionAndPlaylistViews.swift
new file mode 100644
index 0000000..29f20cb
--- /dev/null
+++ b/PlexBar/Views/PlexCollectionAndPlaylistViews.swift
@@ -0,0 +1,507 @@
+import PlexModels
+import SwiftUI
+
+struct PlexCollectionsView: View {
+ let libraries: [PlexLibrary]
+ @Bindable var browserStore: PlexBrowserStore
+ @Bindable var settingsStore: PlexSettingsStore
+ @Bindable var connectionStore: PlexConnectionStore
+ @Bindable var playerCoordinator: PlexPlayerCoordinator
+
+ var body: some View {
+ List {
+ ForEach(libraries) { library in
+ PlexCollectionSection(
+ library: library,
+ browserStore: browserStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator
+ )
+ }
+ }
+ .listStyle(.inset)
+ .navigationTitle("Collections")
+ .focusedSceneValue(
+ \.plexRefreshCommand,
+ PlexFocusedCommandAction(
+ title: "Refresh Collections",
+ isEnabled: !libraries.isEmpty,
+ perform: refresh
+ )
+ )
+ .task {
+ await browserStore.loadLibraryProviderCapabilities()
+ }
+ .toolbar {
+ ToolbarItem {
+ Button("Refresh Collections", systemImage: "arrow.clockwise", action: refresh)
+ .disabled(libraries.isEmpty)
+ }
+ }
+ }
+
+ private func refresh() {
+ for library in libraries {
+ Task {
+ await browserStore.loadCollections(in: library, forceRefresh: true)
+ }
+ }
+ }
+}
+
+private struct PlexCollectionSection: View {
+ let library: PlexLibrary
+ @Bindable var browserStore: PlexBrowserStore
+ @Bindable var settingsStore: PlexSettingsStore
+ @Bindable var connectionStore: PlexConnectionStore
+ @Bindable var playerCoordinator: PlexPlayerCoordinator
+ @State private var presentedEditor: CollectionEditorRequest?
+ @State private var deletionCandidate: PlexMediaItem?
+ @State private var mutationErrorMessage: String?
+
+ var body: some View {
+ Section {
+ if browserStore.isLoadingCollections(in: library), collections.isEmpty {
+ ProgressView()
+ .frame(maxWidth: .infinity, minHeight: 60)
+ .accessibilityLabel("Loading collections in \(library.title)")
+ } else if let errorMessage = browserStore.collectionsErrorMessage(in: library),
+ collections.isEmpty {
+ VStack(alignment: .leading, spacing: 8) {
+ Text(errorMessage)
+ .foregroundStyle(.secondary)
+ Button("Try Again", action: refresh)
+ }
+ .padding(.vertical, 8)
+ } else if collections.isEmpty {
+ Text("No Collections")
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(collections) { collection in
+ NavigationLink(value: PlexNavigationRoute.media(PlexMediaRoute(item: collection))) {
+ PlexMediaChildRow(
+ item: collection,
+ settingsStore: settingsStore,
+ serverURL: connectionStore.resolvedServerURL
+ )
+ }
+ .contextMenu {
+ if browserStore.supportsCollectionManagement {
+ Button("Rename Collection", systemImage: "pencil") {
+ presentedEditor = .rename(collection)
+ }
+ Button("Delete Collection", systemImage: "trash", role: .destructive) {
+ deletionCandidate = collection
+ }
+ .disabled(browserStore.isManagingCollectionOrPlaylist(collection))
+ }
+ }
+ .task {
+ await browserStore.loadMoreCollectionsIfNeeded(
+ in: library,
+ currentItem: collection
+ )
+ }
+ }
+
+ if browserStore.isLoadingCollections(in: library) {
+ ProgressView()
+ .frame(maxWidth: .infinity, minHeight: 44)
+ .accessibilityLabel("Loading more collections in \(library.title)")
+ }
+ }
+ } header: {
+ HStack {
+ Text(library.title)
+ Spacer()
+ if browserStore.supportsCollectionManagement {
+ Button("New Collection", systemImage: "plus") {
+ presentedEditor = .create
+ }
+ .labelStyle(.iconOnly)
+ .disabled(browserStore.isCreatingCollection(in: library))
+ .accessibilityLabel("New collection in \(library.title)")
+ }
+ }
+ }
+ .task(id: library.id) {
+ await browserStore.loadCollections(in: library)
+ }
+ .sheet(item: $presentedEditor) { request in
+ PlexCollectionEditorSheet(
+ request: request,
+ library: library,
+ browserStore: browserStore
+ )
+ }
+ .confirmationDialog(
+ "Delete Collection?",
+ isPresented: Binding(
+ get: { deletionCandidate != nil },
+ set: { isPresented in
+ if !isPresented {
+ deletionCandidate = nil
+ }
+ }
+ ),
+ presenting: deletionCandidate
+ ) { collection in
+ Button("Delete \(collection.title)", role: .destructive) {
+ delete(collection)
+ }
+ } message: { collection in
+ Text("This removes the collection from Plex. Its media files are not deleted.")
+ }
+ .alert(
+ "Couldn’t Change Collection",
+ isPresented: Binding(
+ get: { mutationErrorMessage != nil },
+ set: { isPresented in
+ if !isPresented {
+ mutationErrorMessage = nil
+ }
+ }
+ )
+ ) {
+ Button("OK") {}
+ } message: {
+ Text(mutationErrorMessage ?? "Unknown Plex error.")
+ }
+ }
+
+ private var collections: [PlexMediaItem] {
+ browserStore.collections(in: library)
+ }
+
+ private func refresh() {
+ Task {
+ await browserStore.loadCollections(in: library, forceRefresh: true)
+ }
+ }
+
+ private func delete(_ collection: PlexMediaItem) {
+ deletionCandidate = nil
+ Task {
+ do {
+ try await browserStore.deleteCollection(collection, in: library)
+ } catch {
+ mutationErrorMessage = error.localizedDescription
+ }
+ }
+ }
+}
+
+struct PlexPlaylistsView: View {
+ @Bindable var browserStore: PlexBrowserStore
+ @Bindable var settingsStore: PlexSettingsStore
+ @Bindable var connectionStore: PlexConnectionStore
+ @Bindable var playerCoordinator: PlexPlayerCoordinator
+ @State private var presentedEditor: PlexMediaItem?
+ @State private var deletionCandidate: PlexMediaItem?
+ @State private var mutationErrorMessage: String?
+
+ var body: some View {
+ Group {
+ if browserStore.isLoadingPlaylists, browserStore.playlists.isEmpty {
+ ProgressView()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .accessibilityLabel("Loading Playlists")
+ } else if let errorMessage = browserStore.playlistsErrorMessage,
+ browserStore.playlists.isEmpty {
+ ContentUnavailableView {
+ Label("Couldn’t Load Playlists", systemImage: "exclamationmark.triangle")
+ } description: {
+ Text(errorMessage)
+ } actions: {
+ Button("Try Again", action: refresh)
+ }
+ } else if browserStore.playlists.isEmpty {
+ ContentUnavailableView("No Playlists", systemImage: "music.note.list")
+ } else {
+ List(browserStore.playlists) { playlist in
+ NavigationLink(value: PlexNavigationRoute.media(PlexMediaRoute(item: playlist))) {
+ PlexMediaChildRow(
+ item: playlist,
+ settingsStore: settingsStore,
+ serverURL: connectionStore.resolvedServerURL
+ )
+ }
+ .contextMenu {
+ if browserStore.supportsPlaylistManagement(for: playlist) {
+ Button("Rename Playlist", systemImage: "pencil") {
+ presentedEditor = playlist
+ }
+ Button("Delete Playlist", systemImage: "trash", role: .destructive) {
+ deletionCandidate = playlist
+ }
+ .disabled(browserStore.isManagingCollectionOrPlaylist(playlist))
+ }
+ }
+ .task {
+ await browserStore.loadMorePlaylistsIfNeeded(currentItem: playlist)
+ }
+ }
+ .listStyle(.inset)
+ }
+ }
+ .navigationTitle("Playlists")
+ .focusedSceneValue(
+ \.plexRefreshCommand,
+ PlexFocusedCommandAction(
+ title: "Refresh Playlists",
+ isEnabled: !browserStore.isLoadingPlaylists,
+ perform: refresh
+ )
+ )
+ .task {
+ await browserStore.loadPlaylists()
+ }
+ .sheet(item: $presentedEditor) { playlist in
+ PlexPlaylistEditorSheet(playlist: playlist, browserStore: browserStore)
+ }
+ .confirmationDialog(
+ "Delete Playlist?",
+ isPresented: Binding(
+ get: { deletionCandidate != nil },
+ set: { isPresented in
+ if !isPresented {
+ deletionCandidate = nil
+ }
+ }
+ ),
+ presenting: deletionCandidate
+ ) { playlist in
+ Button("Delete \(playlist.title)", role: .destructive) {
+ delete(playlist)
+ }
+ } message: { _ in
+ Text("This permanently removes the playlist from Plex.")
+ }
+ .alert(
+ "Couldn’t Change Playlist",
+ isPresented: Binding(
+ get: { mutationErrorMessage != nil },
+ set: { isPresented in
+ if !isPresented {
+ mutationErrorMessage = nil
+ }
+ }
+ )
+ ) {
+ Button("OK") {}
+ } message: {
+ Text(mutationErrorMessage ?? "Unknown Plex error.")
+ }
+ .toolbar {
+ ToolbarItem {
+ Button("Refresh Playlists", systemImage: "arrow.clockwise", action: refresh)
+ .disabled(browserStore.isLoadingPlaylists)
+ }
+ }
+ }
+
+ private func refresh() {
+ Task {
+ await browserStore.loadPlaylists(forceRefresh: true)
+ }
+ }
+
+ private func delete(_ playlist: PlexMediaItem) {
+ deletionCandidate = nil
+ Task {
+ do {
+ try await browserStore.deletePlaylist(playlist)
+ } catch {
+ mutationErrorMessage = error.localizedDescription
+ }
+ }
+ }
+}
+
+private enum CollectionEditorRequest: Identifiable {
+ case create
+ case rename(PlexMediaItem)
+
+ var id: String {
+ switch self {
+ case .create:
+ "create"
+ case .rename(let collection):
+ "rename:\(collection.ratingKey)"
+ }
+ }
+
+ var title: String {
+ switch self {
+ case .create:
+ "New Collection"
+ case .rename:
+ "Rename Collection"
+ }
+ }
+
+ var initialName: String {
+ switch self {
+ case .create:
+ ""
+ case .rename(let collection):
+ collection.title
+ }
+ }
+}
+
+private struct PlexCollectionEditorSheet: View {
+ @Environment(\.dismiss) private var dismiss
+ let request: CollectionEditorRequest
+ let library: PlexLibrary
+ @Bindable var browserStore: PlexBrowserStore
+ @State private var name: String
+ @State private var isSaving = false
+ @State private var errorMessage: String?
+ @FocusState private var isNameFocused: Bool
+
+ init(
+ request: CollectionEditorRequest,
+ library: PlexLibrary,
+ browserStore: PlexBrowserStore
+ ) {
+ self.request = request
+ self.library = library
+ self.browserStore = browserStore
+ _name = State(initialValue: request.initialName)
+ }
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ TextField("Name", text: $name)
+ .focused($isNameFocused)
+ .onSubmit(save)
+ }
+ .formStyle(.grouped)
+ .navigationTitle(request.title)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel", action: dismiss.callAsFunction)
+ .keyboardShortcut(.cancelAction)
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button(isSaving ? "Saving…" : "Save", action: save)
+ .disabled(isSaving || name.nilIfBlank == nil)
+ .keyboardShortcut(.defaultAction)
+ }
+ }
+ }
+ .frame(minWidth: 420, minHeight: 180)
+ .onAppear { isNameFocused = true }
+ .alert(
+ "Couldn’t Save Collection",
+ isPresented: Binding(
+ get: { errorMessage != nil },
+ set: { isPresented in
+ if !isPresented {
+ errorMessage = nil
+ }
+ }
+ )
+ ) {
+ Button("OK") {}
+ } message: {
+ Text(errorMessage ?? "Unknown Plex error.")
+ }
+ }
+
+ private func save() {
+ guard !isSaving, name.nilIfBlank != nil else {
+ return
+ }
+ isSaving = true
+ Task {
+ defer { isSaving = false }
+ do {
+ switch request {
+ case .create:
+ _ = try await browserStore.createCollection(named: name, in: library)
+ case .rename(let collection):
+ try await browserStore.renameCollection(collection, to: name, in: library)
+ }
+ dismiss()
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+}
+
+private struct PlexPlaylistEditorSheet: View {
+ @Environment(\.dismiss) private var dismiss
+ let playlist: PlexMediaItem
+ @Bindable var browserStore: PlexBrowserStore
+ @State private var name: String
+ @State private var isSaving = false
+ @State private var errorMessage: String?
+ @FocusState private var isNameFocused: Bool
+
+ init(playlist: PlexMediaItem, browserStore: PlexBrowserStore) {
+ self.playlist = playlist
+ self.browserStore = browserStore
+ _name = State(initialValue: playlist.title)
+ }
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ TextField("Name", text: $name)
+ .focused($isNameFocused)
+ .onSubmit(save)
+ }
+ .formStyle(.grouped)
+ .navigationTitle("Rename Playlist")
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel", action: dismiss.callAsFunction)
+ .keyboardShortcut(.cancelAction)
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button(isSaving ? "Saving…" : "Save", action: save)
+ .disabled(isSaving || name.nilIfBlank == nil)
+ .keyboardShortcut(.defaultAction)
+ }
+ }
+ }
+ .frame(minWidth: 420, minHeight: 180)
+ .onAppear { isNameFocused = true }
+ .alert(
+ "Couldn’t Save Playlist",
+ isPresented: Binding(
+ get: { errorMessage != nil },
+ set: { isPresented in
+ if !isPresented {
+ errorMessage = nil
+ }
+ }
+ )
+ ) {
+ Button("OK") {}
+ } message: {
+ Text(errorMessage ?? "Unknown Plex error.")
+ }
+ }
+
+ private func save() {
+ guard !isSaving, name.nilIfBlank != nil else {
+ return
+ }
+ isSaving = true
+ Task {
+ defer { isSaving = false }
+ do {
+ try await browserStore.renamePlaylist(playlist, to: name)
+ dismiss()
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+}
diff --git a/PlexBar/Views/PlexDownloadSettingsView.swift b/PlexBar/Views/PlexDownloadSettingsView.swift
new file mode 100644
index 0000000..45b8da2
--- /dev/null
+++ b/PlexBar/Views/PlexDownloadSettingsView.swift
@@ -0,0 +1,66 @@
+import SwiftUI
+
+struct PlexDownloadSettingsView: View {
+ @Bindable var settingsStore: PlexSettingsStore
+
+ var body: some View {
+ Form {
+ Section {
+ LabeledContent("Video") {
+ Picker("Download Video Quality", selection: $settingsStore.downloadVideoQuality) {
+ ForEach(PlexDownloadVideoQuality.allCases) { quality in
+ Text(quality.label)
+ .tag(quality)
+ }
+ }
+ .labelsHidden()
+ }
+
+ LabeledContent("Music") {
+ Picker("Download Music Quality", selection: $settingsStore.downloadMusicQuality) {
+ ForEach(PlexMusicQuality.allCases) { quality in
+ Text(quality.label)
+ .tag(quality)
+ }
+ }
+ .labelsHidden()
+ }
+ } header: {
+ Text("Quality")
+ } footer: {
+ Text("These settings apply only to downloads created after they change.")
+ }
+
+ Section {
+ LabeledContent("Selected Subtitles") {
+ Picker(
+ "Downloaded Subtitles",
+ selection: $settingsStore.downloadSubtitlePreference
+ ) {
+ ForEach(PlexDownloadSubtitlePreference.allCases) { preference in
+ Text(preference.label)
+ .tag(preference)
+ }
+ }
+ .labelsHidden()
+ }
+ } header: {
+ Text("Subtitles")
+ } footer: {
+ Text(subtitleExplanation)
+ }
+ }
+ .formStyle(.grouped)
+ }
+
+ private var subtitleExplanation: String {
+ switch settingsStore.downloadSubtitlePreference {
+ case .selectable:
+ "Selected subtitles are stored as a switchable text track. Complex formatting may be simplified."
+ case .burn:
+ "Selected subtitles are rendered permanently into downloaded video."
+ case .none:
+ "Selected subtitles are not included in new downloads."
+ }
+ }
+}
diff --git a/PlexBar/Views/PlexDownloadsView.swift b/PlexBar/Views/PlexDownloadsView.swift
new file mode 100644
index 0000000..13b7d23
--- /dev/null
+++ b/PlexBar/Views/PlexDownloadsView.swift
@@ -0,0 +1,422 @@
+import AppKit
+import SwiftUI
+
+struct PlexDownloadsView: View {
+ @Bindable var downloadsStore: PlexDownloadsStore
+ @Bindable var playerCoordinator: PlexPlayerCoordinator
+ @State private var pendingRemoval: PlexOfflineMedia?
+ @State private var pendingRuleRemoval: PlexAutomaticDownloadRule?
+ @State private var errorMessage: String?
+
+ var body: some View {
+ Group {
+ if let startupErrorMessage = downloadsStore.startupErrorMessage,
+ downloadsStore.jobs.isEmpty,
+ downloadsStore.downloadedMedia.isEmpty,
+ downloadsStore.automaticDownloadRules.isEmpty {
+ ContentUnavailableView {
+ Label("Couldn’t Load Downloads", systemImage: "exclamationmark.triangle")
+ } description: {
+ Text(startupErrorMessage)
+ } actions: {
+ Button("Try Again") {
+ Task { await downloadsStore.reload() }
+ }
+ }
+ } else if downloadsStore.jobs.isEmpty
+ && downloadsStore.downloadedMedia.isEmpty
+ && downloadsStore.automaticDownloadRules.isEmpty {
+ ContentUnavailableView("No Downloads", systemImage: "arrow.down.circle")
+ } else {
+ List {
+ if !downloadsStore.automaticDownloadRules.isEmpty {
+ Section("Automatic Downloads") {
+ ForEach(downloadsStore.automaticDownloadRules) { rule in
+ PlexAutomaticDownloadRuleRow(
+ rule: rule,
+ isRefreshing: downloadsStore.refreshingAutomaticRuleIDs.contains(rule.id),
+ onRefresh: {
+ Task {
+ do {
+ try await downloadsStore.refreshAutomaticDownload(ruleID: rule.id)
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+ },
+ onRemove: { pendingRuleRemoval = rule }
+ )
+ }
+ }
+ }
+
+ if !downloadsStore.activeJobs.isEmpty {
+ Section("Downloading") {
+ ForEach(downloadsStore.activeJobs) { job in
+ PlexDownloadJobRow(
+ job: job,
+ progress: downloadsStore.transferProgress[job.id],
+ onPause: { Task { await downloadsStore.pause(jobID: job.id) } },
+ onResume: { Task { await downloadsStore.resume(jobID: job.id) } },
+ onCancel: { Task { await downloadsStore.cancel(jobID: job.id) } }
+ )
+ }
+ }
+ }
+
+ if !downloadsStore.failedJobs.isEmpty {
+ Section("Failed") {
+ ForEach(downloadsStore.failedJobs) { job in
+ PlexFailedDownloadRow(
+ job: job,
+ onRetry: { Task { await downloadsStore.retry(jobID: job.id) } },
+ onRemove: { Task { await downloadsStore.cancel(jobID: job.id) } }
+ )
+ }
+ }
+ }
+
+ if !downloadsStore.downloadedMedia.isEmpty {
+ Section("Downloaded") {
+ ForEach(downloadsStore.downloadedMedia) { media in
+ PlexOfflineMediaRow(
+ media: media,
+ syncErrorMessage: downloadsStore.syncErrorMessages[media.id],
+ onPlay: { play(media) },
+ onRemove: { pendingRemoval = media }
+ )
+ }
+ }
+ }
+ }
+ .listStyle(.inset)
+ }
+ }
+ .navigationTitle("Downloads")
+ .focusedSceneValue(
+ \.plexRefreshCommand,
+ PlexFocusedCommandAction(
+ title: "Refresh Downloads",
+ isEnabled: true,
+ perform: refresh
+ )
+ )
+ .toolbar {
+ ToolbarItem {
+ Button("Refresh Downloads", systemImage: "arrow.clockwise", action: refresh)
+ }
+ }
+ .confirmationDialog(
+ "Remove Download?",
+ isPresented: Binding(
+ get: { pendingRemoval != nil },
+ set: { if !$0 { pendingRemoval = nil } }
+ ),
+ presenting: pendingRemoval
+ ) { media in
+ Button("Remove Download", role: .destructive) {
+ remove(media)
+ }
+ } message: { media in
+ Text("This removes the downloaded copy of \(media.item.title) from this Mac.")
+ }
+ .confirmationDialog(
+ "Remove Automatic Download?",
+ isPresented: Binding(
+ get: { pendingRuleRemoval != nil },
+ set: { if !$0 { pendingRuleRemoval = nil } }
+ ),
+ presenting: pendingRuleRemoval
+ ) { rule in
+ Button("Remove Automatic Download", role: .destructive) {
+ remove(rule)
+ }
+ } message: { rule in
+ if rule.keepsUpToDate {
+ Text("New episodes of \(rule.title) will no longer download. Existing downloads remain on this Mac.")
+ } else {
+ Text("This removes the saved rule for \(rule.title). Existing downloads remain on this Mac.")
+ }
+ }
+ .alert(
+ "Download Error",
+ isPresented: Binding(
+ get: { errorMessage != nil },
+ set: { if !$0 { errorMessage = nil } }
+ )
+ ) {
+ Button("OK") {}
+ } message: {
+ Text(errorMessage ?? "Unknown download error.")
+ }
+ }
+
+ private func refresh() {
+ Task {
+ await downloadsStore.reload()
+ await downloadsStore.synchronizeOfflineProgress()
+ }
+ }
+
+ private func play(_ media: PlexOfflineMedia) {
+ Task {
+ do {
+ let presentation = try await downloadsStore.playbackPresentation(for: media)
+ playerCoordinator.present(presentation)
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ private func remove(_ media: PlexOfflineMedia) {
+ pendingRemoval = nil
+ Task {
+ do {
+ try await downloadsStore.remove(packageID: media.id)
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ private func remove(_ rule: PlexAutomaticDownloadRule) {
+ pendingRuleRemoval = nil
+ Task {
+ do {
+ try await downloadsStore.removeAutomaticDownloadRule(rule)
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+}
+
+private struct PlexAutomaticDownloadRuleRow: View {
+ let rule: PlexAutomaticDownloadRule
+ let isRefreshing: Bool
+ let onRefresh: () -> Void
+ let onRemove: () -> Void
+
+ var body: some View {
+ HStack(spacing: 14) {
+ Image(systemName: rule.sourceType == "season" ? "rectangle.stack" : "tv")
+ .frame(width: 28)
+ .foregroundStyle(.secondary)
+ VStack(alignment: .leading, spacing: 3) {
+ Text(rule.title)
+ .font(.headline)
+ Text(detailText)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ if let errorMessage = rule.lastErrorMessage {
+ Text(errorMessage)
+ .font(.caption)
+ .foregroundStyle(.red)
+ .lineLimit(2)
+ }
+ }
+ Spacer()
+ if isRefreshing {
+ ProgressView()
+ .controlSize(.small)
+ }
+ Menu("More", systemImage: "ellipsis.circle") {
+ Button("Refresh", systemImage: "arrow.clockwise", action: onRefresh)
+ .disabled(isRefreshing)
+ Divider()
+ Button("Remove Automatic Download", systemImage: "trash", role: .destructive, action: onRemove)
+ }
+ .menuStyle(.borderlessButton)
+ .fixedSize()
+ }
+ .padding(.vertical, 5)
+ }
+
+ private var detailText: String {
+ let updating = rule.keepsUpToDate ? "Downloads new episodes" : "Current episodes only"
+ let removal = rule.removesWatchedDownloads ? "Removes watched downloads" : nil
+ return [rule.policy.title, updating, removal]
+ .compactMap { $0 }
+ .joined(separator: " · ")
+ }
+}
+
+private struct PlexDownloadJobRow: View {
+ let job: PlexDownloadJob
+ let progress: PlexDownloadTransferProgress?
+ let onPause: () -> Void
+ let onResume: () -> Void
+ let onCancel: () -> Void
+
+ var body: some View {
+ HStack(spacing: 14) {
+ Image(systemName: job.mediaType == "track" ? "music.note" : "film")
+ .frame(width: 28)
+ .foregroundStyle(.secondary)
+
+ VStack(alignment: .leading, spacing: 6) {
+ Text(job.title)
+ .font(.headline)
+ ProgressView(value: fractionCompleted)
+ .accessibilityLabel("Download progress for \(job.title)")
+ .accessibilityValue(progressAccessibilityValue)
+ Text(statusText)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ if job.state == .paused {
+ Button("Resume", systemImage: "play.fill", action: onResume)
+ .labelStyle(.iconOnly)
+ .help("Resume Download")
+ } else if job.state == .transferring {
+ Button("Pause", systemImage: "pause.fill", action: onPause)
+ .labelStyle(.iconOnly)
+ .help("Pause Download")
+ }
+
+ Button("Cancel", systemImage: "xmark", action: onCancel)
+ .labelStyle(.iconOnly)
+ .help("Cancel Download")
+ }
+ .padding(.vertical, 5)
+ }
+
+ private var fractionCompleted: Double? {
+ progress?.fractionCompleted ?? job.serverPreparationProgress
+ }
+
+ private var statusText: String {
+ switch job.state {
+ case .waitingForServer:
+ if let errorMessage = job.errorMessage {
+ return errorMessage
+ }
+ if let progress = job.serverPreparationProgress {
+ return "Preparing on Plex Server · \(progress.formatted(.percent.precision(.fractionLength(0))))"
+ }
+ return "Preparing on Plex Server"
+ case .transferring:
+ if let progress {
+ let received = ByteCountFormatter.string(fromByteCount: progress.bytesReceived, countStyle: .file)
+ if let expected = progress.bytesExpected {
+ let total = ByteCountFormatter.string(fromByteCount: expected, countStyle: .file)
+ return "\(received) of \(total)"
+ }
+ return received
+ }
+ return "Downloading"
+ case .paused:
+ return "Paused"
+ case .failed:
+ return job.errorMessage ?? "Failed"
+ }
+ }
+
+ private var progressAccessibilityValue: String {
+ fractionCompleted?.formatted(.percent.precision(.fractionLength(0))) ?? statusText
+ }
+}
+
+private struct PlexFailedDownloadRow: View {
+ let job: PlexDownloadJob
+ let onRetry: () -> Void
+ let onRemove: () -> Void
+
+ var body: some View {
+ HStack(spacing: 14) {
+ Image(systemName: "exclamationmark.triangle")
+ .frame(width: 28)
+ .foregroundStyle(.secondary)
+ VStack(alignment: .leading, spacing: 3) {
+ Text(job.title)
+ .font(.headline)
+ Text(job.errorMessage ?? "Download failed.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+ Spacer()
+ Button("Retry", systemImage: "arrow.clockwise", action: onRetry)
+ Button("Remove", systemImage: "trash", role: .destructive, action: onRemove)
+ .labelStyle(.iconOnly)
+ }
+ .padding(.vertical, 5)
+ }
+}
+
+private struct PlexOfflineMediaRow: View {
+ let media: PlexOfflineMedia
+ let syncErrorMessage: String?
+ let onPlay: () -> Void
+ let onRemove: () -> Void
+
+ var body: some View {
+ HStack(spacing: 14) {
+ PlexDownloadedArtwork(
+ url: media.package.artworkURL,
+ placeholderSystemImage: media.item.type == "track" ? "music.note" : "film"
+ )
+ VStack(alignment: .leading, spacing: 3) {
+ Text(media.item.title)
+ .font(.headline)
+ Text(detailText)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ if let syncErrorMessage {
+ Text(syncErrorMessage)
+ .font(.caption)
+ .foregroundStyle(.orange)
+ .lineLimit(2)
+ }
+ }
+ Spacer()
+ Button("Play", systemImage: "play.fill", action: onPlay)
+ .buttonStyle(.borderedProminent)
+ Menu("More", systemImage: "ellipsis.circle") {
+ Button("Remove Download", systemImage: "trash", role: .destructive, action: onRemove)
+ }
+ .menuStyle(.borderlessButton)
+ .fixedSize()
+ }
+ .padding(.vertical, 5)
+ }
+
+ private var detailText: String {
+ let size = ByteCountFormatter.string(
+ fromByteCount: media.package.manifest.mediaByteCount,
+ countStyle: .file
+ )
+ let date = media.package.manifest.completedAt.formatted(date: .abbreviated, time: .shortened)
+ return "\(size) · \(date)"
+ }
+}
+
+private struct PlexDownloadedArtwork: View {
+ let url: URL?
+ let placeholderSystemImage: String
+
+ var body: some View {
+ Group {
+ if let url, let image = NSImage(contentsOf: url) {
+ Image(nsImage: image)
+ .resizable()
+ .scaledToFill()
+ } else {
+ Image(systemName: placeholderSystemImage)
+ .resizable()
+ .scaledToFit()
+ .padding(12)
+ .foregroundStyle(.secondary)
+ .background(.quaternary)
+ }
+ }
+ .frame(width: 44, height: 64)
+ .compositingGroup()
+ .clipShape(.rect(cornerRadius: 6))
+ .accessibilityHidden(true)
+ }
+}
diff --git a/PlexBar/Views/PlexExternalRatingsView.swift b/PlexBar/Views/PlexExternalRatingsView.swift
new file mode 100644
index 0000000..b131209
--- /dev/null
+++ b/PlexBar/Views/PlexExternalRatingsView.swift
@@ -0,0 +1,237 @@
+import PlexModels
+import SwiftUI
+
+struct PlexExternalRatingsView: View {
+ let ratings: [PlexExternalRatingPresentation]
+ var valueFont: Font
+
+ init(item: PlexMediaItem, valueFont: Font = .headline.weight(.semibold)) {
+ ratings = PlexExternalRatingsPresentation(item: item).ratings
+ self.valueFont = valueFont
+ }
+
+ var body: some View {
+ if !ratings.isEmpty {
+ ViewThatFits(in: .horizontal) {
+ HStack(spacing: 22) {
+ ratingViews
+ }
+
+ VStack(alignment: .leading, spacing: 8) {
+ ratingViews
+ }
+ }
+ .accessibilityElement(children: .contain)
+ }
+ }
+
+ @ViewBuilder
+ private var ratingViews: some View {
+ ForEach(ratings) { rating in
+ PlexExternalRatingView(rating: rating, valueFont: valueFont)
+ }
+ }
+}
+
+private struct PlexExternalRatingView: View {
+ @Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion
+ let rating: PlexExternalRatingPresentation
+ let valueFont: Font
+
+ var body: some View {
+ #if os(tvOS)
+ ratingContent
+ .accessibilityLabel(rating.accessibilityLabel)
+ #else
+ if let destinationURL = rating.destinationURL {
+ Link(destination: destinationURL) {
+ ratingContent
+ }
+ .buttonStyle(.plain)
+ .help("Open on IMDb")
+ .accessibilityLabel(rating.accessibilityLabel)
+ .accessibilityHint("Opens the IMDb title page in your default browser")
+ } else {
+ ratingContent
+ .accessibilityLabel(rating.accessibilityLabel)
+ }
+ #endif
+ }
+
+ private var ratingContent: some View {
+ HStack(spacing: 7) {
+ ratingMark
+ .accessibilityHidden(true)
+
+ Text(rating.displayValue)
+ .font(valueFont.monospacedDigit())
+ .foregroundStyle(.white.opacity(0.92))
+ .contentTransition(.numericText())
+ .animation(
+ PlexMotion.contentReplacementAnimation(
+ reduceMotion: accessibilityReduceMotion
+ ),
+ value: rating.displayValue
+ )
+ .accessibilityHidden(true)
+ }
+ .fixedSize()
+ .accessibilityElement(children: .ignore)
+ }
+
+ @ViewBuilder
+ private var ratingMark: some View {
+ switch rating.source {
+ case .imdb:
+ PlexIMDbMark()
+ case .rottenTomatoes:
+ PlexTomatometerMark(isFresh: rating.isFresh == true)
+ }
+ }
+}
+
+private struct PlexIMDbMark: View {
+ @ScaledMetric(relativeTo: .headline) private var width = 35.0
+ @ScaledMetric(relativeTo: .headline) private var height = 18.0
+ @ScaledMetric(relativeTo: .headline) private var textSize = 9.5
+
+ var body: some View {
+ Text("IMDb")
+ .font(.system(size: textSize, weight: .black, design: .default))
+ .foregroundStyle(.black)
+ .frame(width: width, height: height)
+ .background(Color(red: 0.96, green: 0.78, blue: 0.12))
+ .clipShape(.rect(cornerRadius: height * 0.14))
+ }
+}
+
+private struct PlexTomatometerMark: View {
+ let isFresh: Bool
+ @ScaledMetric(relativeTo: .headline) private var size = 22.0
+
+ var body: some View {
+ Group {
+ if isFresh {
+ PlexFreshTomatoMark()
+ } else {
+ PlexRottenTomatoMark()
+ }
+ }
+ .frame(width: size, height: size)
+ }
+}
+
+private struct PlexFreshTomatoMark: View {
+ var body: some View {
+ ZStack {
+ PlexTomatoBodyShape()
+ .fill(Color(red: 0.91, green: 0.13, blue: 0.13))
+ .overlay {
+ PlexTomatoBodyShape()
+ .stroke(Color.white.opacity(0.82), lineWidth: 1.15)
+ }
+
+ Circle()
+ .fill(.white.opacity(0.42))
+ .frame(width: 4.2, height: 3.1)
+ .offset(x: -4.5, y: -2.8)
+
+ PlexTomatoLeavesShape()
+ .fill(Color(red: 0.18, green: 0.57, blue: 0.20))
+ .frame(width: 13, height: 8)
+ .offset(y: -8)
+ }
+ .padding(.top, 2)
+ }
+}
+
+private struct PlexRottenTomatoMark: View {
+ var body: some View {
+ ZStack {
+ PlexRottenSplatShape()
+ .fill(Color(red: 0.36, green: 0.69, blue: 0.20))
+ .overlay {
+ PlexRottenSplatShape()
+ .stroke(Color.white.opacity(0.82), lineWidth: 1.1)
+ }
+
+ Circle()
+ .fill(.white.opacity(0.35))
+ .frame(width: 3.5, height: 2.8)
+ .offset(x: -3.8, y: -3.2)
+ }
+ .padding(1)
+ }
+}
+
+private struct PlexTomatoBodyShape: Shape {
+ func path(in rect: CGRect) -> Path {
+ var path = Path()
+ path.move(to: CGPoint(x: rect.midX, y: rect.minY + rect.height * 0.18))
+ path.addCurve(
+ to: CGPoint(x: rect.maxX - rect.width * 0.06, y: rect.midY),
+ control1: CGPoint(x: rect.maxX - rect.width * 0.14, y: rect.minY),
+ control2: CGPoint(x: rect.maxX, y: rect.minY + rect.height * 0.28)
+ )
+ path.addCurve(
+ to: CGPoint(x: rect.midX, y: rect.maxY - rect.height * 0.04),
+ control1: CGPoint(x: rect.maxX, y: rect.maxY - rect.height * 0.10),
+ control2: CGPoint(x: rect.maxX - rect.width * 0.18, y: rect.maxY)
+ )
+ path.addCurve(
+ to: CGPoint(x: rect.minX + rect.width * 0.06, y: rect.midY),
+ control1: CGPoint(x: rect.minX + rect.width * 0.18, y: rect.maxY),
+ control2: CGPoint(x: rect.minX, y: rect.maxY - rect.height * 0.10)
+ )
+ path.addCurve(
+ to: CGPoint(x: rect.midX, y: rect.minY + rect.height * 0.18),
+ control1: CGPoint(x: rect.minX, y: rect.minY + rect.height * 0.28),
+ control2: CGPoint(x: rect.minX + rect.width * 0.14, y: rect.minY)
+ )
+ return path
+ }
+}
+
+private struct PlexTomatoLeavesShape: Shape {
+ func path(in rect: CGRect) -> Path {
+ var path = Path()
+ let center = CGPoint(x: rect.midX, y: rect.midY)
+ for index in 0..<10 {
+ let angle = Double(index) * .pi / 5 - .pi / 2
+ let radius = index.isMultiple(of: 2) ? rect.width * 0.49 : rect.width * 0.18
+ let point = CGPoint(
+ x: center.x + cos(angle) * radius,
+ y: center.y + sin(angle) * radius * 0.55
+ )
+ if index == 0 {
+ path.move(to: point)
+ } else {
+ path.addLine(to: point)
+ }
+ }
+ path.closeSubpath()
+ return path
+ }
+}
+
+private struct PlexRottenSplatShape: Shape {
+ func path(in rect: CGRect) -> Path {
+ var path = Path()
+ let center = CGPoint(x: rect.midX, y: rect.midY)
+ for index in 0..<20 {
+ let angle = Double(index) * .pi / 10 - .pi / 2
+ let scale: CGFloat = index.isMultiple(of: 2) ? 0.49 : 0.34
+ let point = CGPoint(
+ x: center.x + cos(angle) * rect.width * scale,
+ y: center.y + sin(angle) * rect.height * scale
+ )
+ if index == 0 {
+ path.move(to: point)
+ } else {
+ path.addLine(to: point)
+ }
+ }
+ path.closeSubpath()
+ return path
+ }
+}
diff --git a/PlexBar/Views/PlexGlobalSearchView.swift b/PlexBar/Views/PlexGlobalSearchView.swift
new file mode 100644
index 0000000..04ee060
--- /dev/null
+++ b/PlexBar/Views/PlexGlobalSearchView.swift
@@ -0,0 +1,292 @@
+import PlexModels
+import SwiftUI
+
+struct PlexGlobalSearchView: View {
+ @Bindable var searchStore: PlexGlobalSearchStore
+ @Bindable var browserStore: PlexBrowserStore
+ @Bindable var settingsStore: PlexSettingsStore
+ @Bindable var connectionStore: PlexConnectionStore
+ @Bindable var playerCoordinator: PlexPlayerCoordinator
+
+ var body: some View {
+ Group {
+ if searchStore.normalizedQuery.isEmpty {
+ ContentUnavailableView("Search", systemImage: "magnifyingglass")
+ } else if searchStore.isSearching, searchStore.visibleHubs.isEmpty {
+ ProgressView("Searching…")
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .accessibilityLabel("Searching for \(searchStore.normalizedQuery)")
+ } else if let errorMessage = searchStore.errorMessage,
+ searchStore.visibleHubs.isEmpty {
+ ContentUnavailableView {
+ Label("Couldn’t Search", systemImage: "exclamationmark.triangle")
+ } description: {
+ Text(errorMessage)
+ } actions: {
+ Button("Try Again", action: refresh)
+ }
+ } else if searchStore.hasSearched, searchStore.visibleHubs.isEmpty {
+ ContentUnavailableView.search(text: searchStore.displayedQuery)
+ } else {
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 30) {
+ if let errorMessage = searchStore.errorMessage {
+ PlexGlobalSearchErrorRow(
+ message: errorMessage,
+ retry: refresh
+ )
+ }
+
+ ForEach(searchStore.visibleHubs) { hub in
+ PlexMediaGridSection(
+ title: hub.title,
+ items: hub.metadata,
+ artworkLayout: hub.prefersPosterArtwork ? .poster : .automatic,
+ showAllRoute: showAllRoute(for: hub),
+ browserStore: browserStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator
+ )
+ }
+ }
+ .scenePadding()
+ }
+ }
+ }
+ .overlay(alignment: .top) {
+ if searchStore.isSearching, !searchStore.visibleHubs.isEmpty {
+ ProgressView()
+ .progressViewStyle(.linear)
+ .accessibilityLabel("Searching for \(searchStore.normalizedQuery)")
+ }
+ }
+ .navigationTitle("Search Results")
+ .focusedSceneValue(
+ \.plexRefreshCommand,
+ PlexFocusedCommandAction(
+ title: "Refresh Search Results",
+ isEnabled: !searchStore.normalizedQuery.isEmpty && !searchStore.isSearching,
+ perform: refresh
+ )
+ )
+ .task {
+ await browserStore.loadLibraryProviderCapabilities()
+ }
+ .task(id: searchStore.normalizedQuery) {
+ await searchStore.update(load: search)
+ }
+ .toolbar {
+ ToolbarItem {
+ Button("Refresh Search Results", systemImage: "arrow.clockwise", action: refresh)
+ .disabled(searchStore.normalizedQuery.isEmpty || searchStore.isSearching)
+ }
+ }
+ }
+
+ private func search(_ query: String) async throws -> [PlexHub] {
+ try await browserStore.searchAllLibraries(query: query)
+ }
+
+ private func refresh() {
+ Task {
+ await searchStore.update(forceRefresh: true, load: search)
+ }
+ }
+
+ private func showAllRoute(for hub: PlexHub) -> PlexNavigationRoute? {
+ let totalSize = hub.totalSize ?? hub.size ?? hub.metadata.count
+ guard hub.more || totalSize > hub.metadata.count,
+ let route = PlexSearchHubRoute(
+ hub: hub,
+ query: searchStore.displayedQuery
+ ) else {
+ return nil
+ }
+ return .searchHub(route)
+ }
+}
+
+struct PlexSearchHubItemsView: View {
+ let hub: PlexHub
+ @Bindable var searchStore: PlexGlobalSearchStore
+ @Bindable var browserStore: PlexBrowserStore
+ @Bindable var settingsStore: PlexSettingsStore
+ @Bindable var connectionStore: PlexConnectionStore
+ @Bindable var playerCoordinator: PlexPlayerCoordinator
+ private let artworkPrefetcher = PlexArtworkPrefetcher.shared
+
+ var body: some View {
+ Group {
+ if searchStore.isLoadingItems(in: hub), items.isEmpty {
+ ProgressView()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .accessibilityLabel("Loading \(hub.title)")
+ } else if let errorMessage = searchStore.itemsErrorMessage(in: hub),
+ items.isEmpty {
+ ContentUnavailableView {
+ Label("Couldn’t Load \(hub.title)", systemImage: "exclamationmark.triangle")
+ } description: {
+ Text(errorMessage)
+ } actions: {
+ Button("Try Again", action: refresh)
+ }
+ } else if items.isEmpty {
+ ContentUnavailableView("No Items", systemImage: "rectangle.stack")
+ } else {
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 20) {
+ if let errorMessage = searchStore.itemsErrorMessage(in: hub) {
+ PlexGlobalSearchErrorRow(
+ message: errorMessage,
+ retry: retryPageLoad
+ )
+ }
+
+ LazyVGrid(
+ columns: PlexMediaPosterCard.standardGridColumns,
+ alignment: .leading,
+ spacing: 24
+ ) {
+ ForEach(items) { item in
+ NavigationLink(value: PlexNavigationRoute.media(PlexMediaRoute(item: item))) {
+ PlexMediaPosterCard(
+ item: item,
+ settingsStore: settingsStore,
+ serverURL: connectionStore.resolvedServerURL,
+ artworkLayout: hub.prefersPosterArtwork ? .poster : .automatic
+ )
+ }
+ .plexMediaContextMenu(
+ for: item,
+ browserStore: browserStore,
+ playerCoordinator: playerCoordinator
+ )
+ .buttonStyle(.plain)
+ .task {
+ await prefetchArtwork(after: item)
+ await searchStore.loadMoreItemsIfNeeded(
+ in: hub,
+ currentItem: item,
+ loadPage: loadPage
+ )
+ }
+ }
+
+ if searchStore.isLoadingItems(in: hub) {
+ ProgressView()
+ .frame(maxWidth: .infinity, minHeight: 80)
+ .accessibilityLabel("Loading more \(hub.title)")
+ }
+ }
+ }
+ .scenePadding()
+ }
+ }
+ }
+ .overlay(alignment: .top) {
+ if searchStore.isLoadingItems(in: hub), !items.isEmpty {
+ ProgressView()
+ .progressViewStyle(.linear)
+ .accessibilityLabel("Refreshing \(hub.title)")
+ }
+ }
+ .navigationTitle(hub.title)
+ .focusedSceneValue(
+ \.plexRefreshCommand,
+ PlexFocusedCommandAction(
+ title: "Refresh \(hub.title)",
+ isEnabled: !searchStore.isLoadingItems(in: hub),
+ perform: refresh
+ )
+ )
+ .task(id: hub.key) {
+ await searchStore.loadItems(in: hub, loadPage: loadPage)
+ }
+ .toolbar {
+ ToolbarItem {
+ Button("Refresh \(hub.title)", systemImage: "arrow.clockwise", action: refresh)
+ .disabled(searchStore.isLoadingItems(in: hub))
+ }
+ }
+ }
+
+ private var items: [PlexMediaItem] {
+ searchStore.items(in: hub)
+ }
+
+ private func loadPage(path: String, start: Int, size: Int) async throws -> PlexMediaPage {
+ try await browserStore.globalSearchHubPage(
+ path: path,
+ start: start,
+ size: size
+ )
+ }
+
+ private func refresh() {
+ Task {
+ await searchStore.loadItems(
+ in: hub,
+ forceRefresh: true,
+ loadPage: loadPage
+ )
+ }
+ }
+
+ private func retryPageLoad() {
+ guard let lastItem = items.last,
+ searchStore.hasMoreItems(in: hub) else {
+ refresh()
+ return
+ }
+ Task {
+ await searchStore.loadMoreItemsIfNeeded(
+ in: hub,
+ currentItem: lastItem,
+ loadPage: loadPage
+ )
+ }
+ }
+
+ private func prefetchArtwork(after item: PlexMediaItem) async {
+ let currentItems = items
+ guard let itemIndex = currentItems.firstIndex(where: { $0.id == item.id }) else {
+ return
+ }
+
+ let requests = currentItems
+ .dropFirst(itemIndex + 1)
+ .prefix(6)
+ .compactMap {
+ PlexMediaPosterCard.prefetchRequest(
+ for: $0,
+ serverURL: connectionStore.resolvedServerURL,
+ token: settingsStore.trimmedServerToken,
+ clientContext: PlexClientContext(clientIdentifier: settingsStore.clientIdentifier),
+ artworkLayout: hub.prefersPosterArtwork ? .poster : .automatic,
+ spoilerPolicy: settingsStore.episodeSpoilerPolicy
+ )
+ }
+ await artworkPrefetcher.prefetch(requests)
+ }
+}
+
+struct PlexGlobalSearchErrorRow: View {
+ let message: String
+ let retry: () -> Void
+
+ var body: some View {
+ HStack(alignment: .firstTextBaseline, spacing: 12) {
+ Label("Couldn’t Update Search Results", systemImage: "exclamationmark.triangle")
+
+ Text(message)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+
+ Spacer()
+
+ Button("Try Again", action: retry)
+ }
+ .accessibilityElement(children: .contain)
+ }
+}
diff --git a/PlexBar/Views/PlexHomeView.swift b/PlexBar/Views/PlexHomeView.swift
new file mode 100644
index 0000000..069a85c
--- /dev/null
+++ b/PlexBar/Views/PlexHomeView.swift
@@ -0,0 +1,217 @@
+import PlexModels
+import SwiftUI
+
+struct PlexHomeView: View {
+ @Bindable var browserStore: PlexBrowserStore
+ @Bindable var settingsStore: PlexSettingsStore
+ @Bindable var connectionStore: PlexConnectionStore
+ @Bindable var playerCoordinator: PlexPlayerCoordinator
+
+ var body: some View {
+ Group {
+ if browserStore.isLoadingHomeHubs, browserStore.homeHubs.isEmpty {
+ ProgressView()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .accessibilityLabel("Loading Home")
+ } else if let errorMessage = browserStore.homeHubsErrorMessage,
+ browserStore.homeHubs.isEmpty {
+ ContentUnavailableView {
+ Label("Couldn’t Load Home", systemImage: "exclamationmark.triangle")
+ } description: {
+ Text(errorMessage)
+ } actions: {
+ Button("Try Again", action: refresh)
+ }
+ } else if browserStore.hasLoadedHomeHubs, browserStore.homeHubs.isEmpty {
+ ContentUnavailableView("No Home Content", systemImage: "house")
+ } else {
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 30) {
+ ForEach(browserStore.homeHubs) { hub in
+ PlexMediaHubShelf(
+ hub: hub,
+ showAllRoute: showAllRoute(for: hub),
+ browserStore: browserStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator
+ )
+ }
+ }
+ .scenePadding()
+ }
+ }
+ }
+ .overlay(alignment: .top) {
+ if browserStore.isLoadingHomeHubs, !browserStore.homeHubs.isEmpty {
+ ProgressView()
+ .progressViewStyle(.linear)
+ .accessibilityLabel("Refreshing Home")
+ }
+ }
+ .navigationTitle("Home")
+ .focusedSceneValue(
+ \.plexRefreshCommand,
+ PlexFocusedCommandAction(
+ title: "Refresh Home",
+ isEnabled: !browserStore.isLoadingHomeHubs,
+ perform: refresh
+ )
+ )
+ .task {
+ async let capabilityLoad: Void = browserStore.loadLibraryProviderCapabilities()
+ await browserStore.loadHomeHubs()
+ _ = await capabilityLoad
+ }
+ .toolbar {
+ ToolbarItem {
+ Button("Refresh Home", systemImage: "arrow.clockwise", action: refresh)
+ .disabled(browserStore.isLoadingHomeHubs)
+ }
+ }
+ }
+
+ private func refresh() {
+ Task {
+ await browserStore.loadHomeHubs(forceRefresh: true)
+ }
+ }
+
+ private func showAllRoute(for hub: PlexHub) -> PlexNavigationRoute? {
+ guard hub.key != nil else {
+ return nil
+ }
+ let totalSize = hub.totalSize ?? hub.size ?? hub.metadata.count
+ guard hub.more || totalSize > hub.metadata.count else {
+ return nil
+ }
+ return .homeHub(PlexHomeHubRoute(hub: hub))
+ }
+}
+
+struct PlexHomeHubItemsView: View {
+ let hub: PlexHub
+ @Bindable var browserStore: PlexBrowserStore
+ @Bindable var settingsStore: PlexSettingsStore
+ @Bindable var connectionStore: PlexConnectionStore
+ @Bindable var playerCoordinator: PlexPlayerCoordinator
+ private let artworkPrefetcher = PlexArtworkPrefetcher.shared
+
+ var body: some View {
+ Group {
+ if browserStore.isLoadingHomeHubItems(in: hub), items.isEmpty {
+ ProgressView()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .accessibilityLabel("Loading \(hub.title)")
+ } else if let errorMessage = browserStore.homeHubItemsErrorMessage(in: hub),
+ items.isEmpty {
+ ContentUnavailableView {
+ Label("Couldn’t Load \(hub.title)", systemImage: "exclamationmark.triangle")
+ } description: {
+ Text(errorMessage)
+ } actions: {
+ Button("Try Again", action: refresh)
+ }
+ } else if items.isEmpty {
+ ContentUnavailableView("No Items", systemImage: "rectangle.stack")
+ } else {
+ ScrollView {
+ LazyVGrid(
+ columns: PlexMediaPosterCard.standardGridColumns,
+ alignment: .leading,
+ spacing: 24
+ ) {
+ ForEach(items) { item in
+ NavigationLink(value: PlexNavigationRoute.media(PlexMediaRoute(item: item))) {
+ PlexMediaPosterCard(
+ item: item,
+ settingsStore: settingsStore,
+ serverURL: connectionStore.resolvedServerURL,
+ artworkLayout: hub.prefersPosterArtwork ? .poster : .automatic
+ )
+ }
+ .plexMediaContextMenu(
+ for: item,
+ allowsRemovalFromContinueWatching: hub.isContinueWatching,
+ browserStore: browserStore,
+ playerCoordinator: playerCoordinator
+ )
+ .buttonStyle(.plain)
+ .task {
+ await prefetchArtwork(after: item)
+ await browserStore.loadMoreHomeHubItemsIfNeeded(
+ in: hub,
+ currentItem: item
+ )
+ }
+ }
+
+ if browserStore.isLoadingHomeHubItems(in: hub) {
+ ProgressView()
+ .frame(maxWidth: .infinity, minHeight: 80)
+ .accessibilityLabel("Loading more \(hub.title)")
+ }
+ }
+ .scenePadding()
+ }
+ }
+ }
+ .overlay(alignment: .top) {
+ if browserStore.isLoadingHomeHubItems(in: hub), !items.isEmpty {
+ ProgressView()
+ .progressViewStyle(.linear)
+ .accessibilityLabel("Refreshing \(hub.title)")
+ }
+ }
+ .navigationTitle(hub.title)
+ .focusedSceneValue(
+ \.plexRefreshCommand,
+ PlexFocusedCommandAction(
+ title: "Refresh \(hub.title)",
+ isEnabled: !browserStore.isLoadingHomeHubItems(in: hub),
+ perform: refresh
+ )
+ )
+ .task(id: hub.id) {
+ await browserStore.loadHomeHubItems(in: hub)
+ }
+ .toolbar {
+ ToolbarItem {
+ Button("Refresh \(hub.title)", systemImage: "arrow.clockwise", action: refresh)
+ .disabled(browserStore.isLoadingHomeHubItems(in: hub))
+ }
+ }
+ }
+
+ private var items: [PlexMediaItem] {
+ browserStore.homeHubItems(in: hub)
+ }
+
+ private func refresh() {
+ Task {
+ await browserStore.loadHomeHubItems(in: hub, forceRefresh: true)
+ }
+ }
+
+ private func prefetchArtwork(after item: PlexMediaItem) async {
+ let currentItems = items
+ guard let itemIndex = currentItems.firstIndex(where: { $0.id == item.id }) else {
+ return
+ }
+
+ let requests = currentItems
+ .dropFirst(itemIndex + 1)
+ .prefix(6)
+ .compactMap {
+ PlexMediaPosterCard.prefetchRequest(
+ for: $0,
+ serverURL: connectionStore.resolvedServerURL,
+ token: settingsStore.trimmedServerToken,
+ clientContext: PlexClientContext(clientIdentifier: settingsStore.clientIdentifier),
+ artworkLayout: hub.prefersPosterArtwork ? .poster : .automatic,
+ spoilerPolicy: settingsStore.episodeSpoilerPolicy
+ )
+ }
+ await artworkPrefetcher.prefetch(requests)
+ }
+}
diff --git a/PlexBar/Views/PlexLibraryBrowserView.swift b/PlexBar/Views/PlexLibraryBrowserView.swift
new file mode 100644
index 0000000..ee33fd7
--- /dev/null
+++ b/PlexBar/Views/PlexLibraryBrowserView.swift
@@ -0,0 +1,532 @@
+import PlexModels
+import SwiftUI
+
+struct PlexLibraryBrowserView: View {
+ let library: PlexLibrary
+ @Bindable var searchStore: PlexLibrarySearchStore
+ @Binding var scrollPosition: ScrollPosition
+ @Bindable var browserStore: PlexBrowserStore
+ @Bindable var settingsStore: PlexSettingsStore
+ @Bindable var connectionStore: PlexConnectionStore
+ @Bindable var playerCoordinator: PlexPlayerCoordinator
+ @State private var presentedValueFilter: PlexLibraryFilterDefinition?
+ private let artworkPrefetcher = PlexArtworkPrefetcher.shared
+
+ var body: some View {
+ Group {
+ if browserStore.isLoading(
+ library,
+ searchQuery: searchStore.displayedQuery,
+ browseOptions: searchStore.displayedOptions
+ ) && items.isEmpty {
+ ProgressView()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .accessibilityLabel("Loading \(library.title)")
+ } else if let errorMessage = browserStore.errorMessage(
+ for: library,
+ searchQuery: searchStore.displayedQuery,
+ browseOptions: searchStore.displayedOptions
+ ), items.isEmpty {
+ ContentUnavailableView {
+ Label("Couldn’t Load \(library.title)", systemImage: "exclamationmark.triangle")
+ } description: {
+ Text(errorMessage)
+ } actions: {
+ Button("Try Again", action: refresh)
+ }
+ } else if items.isEmpty {
+ if searchStore.displayedQuery.isEmpty {
+ ContentUnavailableView("No Items", systemImage: library.type.symbolName)
+ } else {
+ ContentUnavailableView.search(text: searchStore.displayedQuery)
+ }
+ } else {
+ ScrollView {
+ LazyVGrid(
+ columns: PlexMediaPosterCard.standardGridColumns,
+ alignment: .leading,
+ spacing: 24
+ ) {
+ ForEach(items) { item in
+ NavigationLink(value: PlexNavigationRoute.media(PlexMediaRoute(item: item))) {
+ PlexMediaPosterCard(
+ item: item,
+ settingsStore: settingsStore,
+ serverURL: connectionStore.resolvedServerURL
+ )
+ }
+ .plexMediaContextMenu(
+ for: item,
+ library: library,
+ browserStore: browserStore,
+ playerCoordinator: playerCoordinator
+ )
+ .buttonStyle(.plain)
+ .task {
+ await prefetchArtwork(after: item)
+ await browserStore.loadMoreIfNeeded(
+ in: library,
+ searchQuery: searchStore.displayedQuery,
+ browseOptions: searchStore.displayedOptions,
+ currentItem: item
+ )
+ }
+ }
+
+ if browserStore.isLoading(
+ library,
+ searchQuery: searchStore.displayedQuery,
+ browseOptions: searchStore.displayedOptions
+ ) {
+ ProgressView()
+ .frame(maxWidth: .infinity, minHeight: 80)
+ .accessibilityLabel("Loading more \(library.title)")
+ }
+ }
+ .scrollTargetLayout()
+ .scenePadding()
+ }
+ .scrollPosition($scrollPosition)
+ }
+ }
+ .overlay(alignment: .top) {
+ searchProgressIndicator
+ }
+ .navigationTitle(library.title)
+ .searchable(text: $searchStore.text, placement: .toolbar, prompt: "Search \(library.title)")
+ .focusedSceneValue(
+ \.plexRefreshCommand,
+ PlexFocusedCommandAction(
+ title: "Refresh \(library.title)",
+ isEnabled: !searchStore.isUpdating
+ && !browserStore.isLoading(
+ library,
+ searchQuery: searchStore.displayedQuery,
+ browseOptions: searchStore.displayedOptions
+ ),
+ perform: refresh
+ )
+ )
+ .task {
+ await browserStore.loadLibraryProviderCapabilities()
+ }
+ .task(id: LibraryBrowseTask(
+ libraryID: library.id,
+ query: searchStore.normalizedQuery,
+ options: searchStore.selectedOptions
+ )) {
+ await searchStore.update { requestedQuery, requestedOptions in
+ await browserStore.load(
+ library,
+ searchQuery: requestedQuery,
+ browseOptions: requestedOptions
+ )
+ }
+ }
+ .onChange(of: LibraryBrowseTask(
+ libraryID: library.id,
+ query: searchStore.normalizedQuery,
+ options: searchStore.selectedOptions
+ )) { oldTask, newTask in
+ guard oldTask != newTask else {
+ return
+ }
+ scrollPosition.scrollTo(edge: .top)
+ }
+ .sheet(item: $presentedValueFilter) { filter in
+ PlexLibraryFilterPicker(
+ filter: filter,
+ browserStore: browserStore,
+ searchStore: searchStore
+ )
+ }
+ .toolbar {
+ ToolbarItemGroup {
+ contentTypeMenu
+ sortMenu
+ filterMenu
+ Button("Refresh \(library.title)", systemImage: "arrow.clockwise", action: refresh)
+ .disabled(
+ searchStore.isUpdating
+ || browserStore.isLoading(
+ library,
+ searchQuery: searchStore.displayedQuery,
+ browseOptions: searchStore.displayedOptions
+ )
+ )
+ }
+ }
+ }
+
+ private var items: [PlexMediaItem] {
+ browserStore.items(
+ in: library,
+ searchQuery: searchStore.displayedQuery,
+ browseOptions: searchStore.displayedOptions
+ )
+ }
+
+ private var browseDefinition: PlexLibraryBrowseDefinition? {
+ try? browserStore.browseDefinition(for: library)?
+ .selecting(searchStore.selectedOptions.contentTypePath)
+ }
+
+ @ViewBuilder
+ private var contentTypeMenu: some View {
+ if let definition = browserStore.browseDefinition(for: library), definition.types.count > 1 {
+ let selectedPath = searchStore.selectedOptions.contentTypePath ?? definition.contentPath
+ let title = definition.types.first { $0.key == selectedPath }?.title ?? "Browse"
+ Menu(title) {
+ ForEach(definition.types) { type in
+ Button {
+ searchStore.selectContentType(
+ path: type.key == definition.contentPath ? nil : type.key
+ )
+ } label: {
+ menuLabel(type.title, isSelected: type.key == selectedPath)
+ }
+ }
+ }
+ .help("Choose the type of items to browse")
+ }
+ }
+
+ private var selectedSortDefinition: PlexLibrarySortDefinition? {
+ guard let selectedSort = searchStore.selectedOptions.sort else {
+ return nil
+ }
+ return browseDefinition?.sorts.first { $0.id == selectedSort.sortID }
+ }
+
+ private var sortMenu: some View {
+ Menu("Sort", systemImage: "arrow.up.arrow.down") {
+ Button(action: selectDefaultSort) {
+ menuLabel("Default", isSelected: searchStore.selectedOptions.sort == nil)
+ }
+
+ if let browseDefinition, !browseDefinition.sorts.isEmpty {
+ Divider()
+ ForEach(browseDefinition.sorts) { sort in
+ Button {
+ searchStore.selectSort(sort)
+ } label: {
+ menuLabel(
+ sort.title,
+ isSelected: searchStore.selectedOptions.sort?.sortID == sort.id
+ )
+ }
+ }
+ }
+
+ if let selectedSortDefinition {
+ Divider()
+ Section("Direction") {
+ Button {
+ searchStore.selectSortDirection(.ascending, definition: selectedSortDefinition)
+ } label: {
+ menuLabel(
+ PlexLibrarySortDirection.ascending.title,
+ isSelected: searchStore.selectedOptions.sort?.direction == .ascending
+ )
+ }
+
+ if selectedSortDefinition.descendingKey != nil {
+ Button {
+ searchStore.selectSortDirection(.descending, definition: selectedSortDefinition)
+ } label: {
+ menuLabel(
+ PlexLibrarySortDirection.descending.title,
+ isSelected: searchStore.selectedOptions.sort?.direction == .descending
+ )
+ }
+ }
+ }
+ }
+ }
+ .disabled(browseDefinition?.sorts.isEmpty != false)
+ .accessibilityLabel("Sort \(library.title)")
+ }
+
+ private var filterMenu: some View {
+ Menu("Filter", systemImage: "line.3.horizontal.decrease") {
+ if let booleanFilters = browseDefinition?.booleanFilters,
+ !booleanFilters.isEmpty {
+ Section("Status") {
+ ForEach(booleanFilters) { filter in
+ Toggle(
+ filter.title,
+ isOn: Binding(
+ get: { searchStore.isBooleanFilterEnabled(filter) },
+ set: { searchStore.setBooleanFilter(filter, isEnabled: $0) }
+ )
+ )
+ }
+ }
+ }
+
+ if let valueFilters = browseDefinition?.valueFilters,
+ !valueFilters.isEmpty {
+ Section("Details") {
+ ForEach(valueFilters) { filter in
+ Button {
+ presentedValueFilter = filter
+ } label: {
+ valueFilterMenuLabel(filter)
+ }
+ }
+ }
+ }
+
+ Divider()
+ Button("Clear Filters", action: searchStore.clearFilters)
+ .disabled(!searchStore.hasSelectedFilters)
+ }
+ .disabled(browseDefinition?.filters.isEmpty != false)
+ .accessibilityLabel("Filter \(library.title)")
+ }
+
+ @ViewBuilder
+ private func valueFilterMenuLabel(_ filter: PlexLibraryFilterDefinition) -> some View {
+ let selectionCount = searchStore.selectedValueCount(for: filter)
+ if selectionCount > 0 {
+ Label("\(filter.title) (\(selectionCount))", systemImage: "checkmark")
+ } else {
+ Text(filter.title)
+ }
+ }
+
+ private var searchProgressIndicator: some View {
+ ProgressView()
+ .progressViewStyle(.linear)
+ .controlSize(.small)
+ .padding(.horizontal, 12)
+ .padding(.top, 4)
+ .opacity(searchStore.isUpdating ? 1 : 0)
+ .accessibilityHidden(!searchStore.isUpdating)
+ .accessibilityLabel(searchProgressAccessibilityLabel)
+ }
+
+ private var searchProgressAccessibilityLabel: String {
+ guard let pendingSearchQuery = searchStore.pendingQuery, !pendingSearchQuery.isEmpty else {
+ return "Updating \(library.title)"
+ }
+ return "Searching \(library.title) for \(pendingSearchQuery)"
+ }
+
+ private func refresh() {
+ Task {
+ await browserStore.load(
+ library,
+ searchQuery: searchStore.displayedQuery,
+ browseOptions: searchStore.displayedOptions,
+ forceRefresh: true
+ )
+ }
+ }
+
+ private func selectDefaultSort() {
+ searchStore.selectSort(nil)
+ }
+
+ private func prefetchArtwork(after item: PlexMediaItem) async {
+ let currentItems = items
+ guard let itemIndex = currentItems.firstIndex(where: { $0.id == item.id }) else {
+ return
+ }
+
+ let requests = currentItems
+ .dropFirst(itemIndex + 1)
+ .prefix(6)
+ .compactMap {
+ PlexMediaPosterCard.prefetchRequest(
+ for: $0,
+ serverURL: connectionStore.resolvedServerURL,
+ token: settingsStore.trimmedServerToken,
+ clientContext: PlexClientContext(clientIdentifier: settingsStore.clientIdentifier),
+ spoilerPolicy: settingsStore.episodeSpoilerPolicy
+ )
+ }
+ await artworkPrefetcher.prefetch(requests)
+ }
+
+ @ViewBuilder
+ private func menuLabel(_ title: String, isSelected: Bool) -> some View {
+ if isSelected {
+ Label(title, systemImage: "checkmark")
+ } else {
+ Text(title)
+ }
+ }
+}
+
+private struct LibraryBrowseTask: Equatable {
+ let libraryID: String
+ let query: String
+ let options: PlexLibraryBrowseOptions
+}
+
+struct PlexMediaPosterCard: View {
+ static let artworkWidth: CGFloat = 180
+ static let standardGridColumns = [
+ GridItem(.adaptive(minimum: artworkWidth, maximum: 205), spacing: 20)
+ ]
+
+ typealias ArtworkLayout = PlexMediaArtworkLayout
+
+ let item: PlexMediaItem
+ let settingsStore: PlexSettingsStore
+ let serverURL: URL?
+ var artworkLayout: ArtworkLayout = .automatic
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 9) {
+ PlexArtworkView(
+ primaryImageURL: artworkURL,
+ fallbackImageURL: nil,
+ token: settingsStore.trimmedServerToken,
+ clientContext: PlexClientContext(clientIdentifier: settingsStore.clientIdentifier),
+ placeholderSymbol: placeholderSymbol,
+ width: Self.artworkWidth,
+ height: artworkHeight,
+ cornerRadius: 12
+ )
+ .plexWatchedIndicator(isWatched: item.isWatched)
+ .frame(maxWidth: .infinity)
+ .overlay(alignment: .bottom) {
+ if let progress = item.progress {
+ ProgressView(value: progress)
+ .tint(.white)
+ .padding(8)
+ .accessibilityHidden(true)
+ }
+ }
+
+ if let title = cardTitle {
+ Text(title)
+ .font(.headline)
+ .lineLimit(2)
+ }
+
+ if let subtitle = cardSubtitle {
+ Text(subtitle)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+ .contentShape(.rect)
+ .accessibilityElement(children: .combine)
+ .accessibilityValue(item.watchStateAccessibilityValue ?? "")
+ }
+
+ private var cardTitle: String? {
+ switch item.type?.lowercased() {
+ case "episode": item.grandparentTitle
+ case "season": item.parentTitle
+ default: item.title
+ }
+ }
+
+ private var cardSubtitle: String? {
+ switch item.type?.lowercased() {
+ case "episode":
+ return PlexEpisodeText.subtitle(season: item.parentIndex, episode: item.index, title: item.title)
+ case "season":
+ return item.title
+ case "show":
+ return nil
+ default:
+ return item.subtitle
+ }
+ }
+
+ private var artworkURL: URL? {
+ Self.artworkURL(
+ for: item,
+ serverURL: serverURL,
+ artworkLayout: artworkLayout,
+ spoilerPolicy: settingsStore.episodeSpoilerPolicy
+ )
+ }
+
+ private var placeholderSymbol: String {
+ spoilerPresentation.isProtected && artworkLayout == .automatic
+ ? "eye.slash"
+ : item.placeholderSymbol
+ }
+
+ private var artworkHeight: CGFloat {
+ Self.artworkHeight(for: item, artworkLayout: artworkLayout)
+ }
+
+ static func prefetchRequest(
+ for item: PlexMediaItem,
+ serverURL: URL?,
+ token: String,
+ clientContext: PlexClientContext,
+ artworkLayout: ArtworkLayout = .automatic,
+ spoilerPolicy: PlexEpisodeSpoilerPolicy
+ ) -> PlexArtworkPrefetchRequest? {
+ guard let artworkURL = artworkURL(
+ for: item,
+ serverURL: serverURL,
+ artworkLayout: artworkLayout,
+ spoilerPolicy: spoilerPolicy
+ ) else {
+ return nil
+ }
+
+ let height = artworkHeight(for: item, artworkLayout: artworkLayout)
+ return PlexArtworkPrefetchRequest(
+ candidateURLs: [artworkURL],
+ token: token,
+ clientContext: clientContext,
+ maximumPixelSize: Int(ceil(max(artworkWidth, height) * 2))
+ )
+ }
+
+ private static func artworkURL(
+ for item: PlexMediaItem,
+ serverURL: URL?,
+ artworkLayout: ArtworkLayout,
+ spoilerPolicy: PlexEpisodeSpoilerPolicy
+ ) -> URL? {
+ guard let serverURL else {
+ return nil
+ }
+
+ let artworkPath = artworkPath(
+ for: item,
+ artworkLayout: artworkLayout,
+ spoilerPolicy: spoilerPolicy
+ )
+ return PlexURLBuilder.mediaURL(serverURL: serverURL, path: artworkPath)
+ }
+
+ private static func artworkPath(
+ for item: PlexMediaItem,
+ artworkLayout: ArtworkLayout,
+ spoilerPolicy: PlexEpisodeSpoilerPolicy
+ ) -> String? {
+ guard artworkLayout != .automatic || !spoilerPolicy.hidesSpoilers(for: item) else { return nil }
+ return PlexMediaArtworkPresentation(item: item, layout: artworkLayout).path
+ }
+
+ private var spoilerPresentation: PlexEpisodeSpoilerPresentation {
+ PlexEpisodeSpoilerPresentation(
+ item: item,
+ policy: settingsStore.episodeSpoilerPolicy
+ )
+ }
+
+ private static func artworkHeight(
+ for item: PlexMediaItem,
+ artworkLayout: ArtworkLayout
+ ) -> CGFloat {
+ switch PlexMediaArtworkPresentation(item: item, layout: artworkLayout).shape {
+ case .poster: 270
+ case .landscape: 101
+ case .square: 180
+ }
+ }
+}
diff --git a/PlexBar/Views/PlexLibraryFilterPicker.swift b/PlexBar/Views/PlexLibraryFilterPicker.swift
new file mode 100644
index 0000000..d7cfa8b
--- /dev/null
+++ b/PlexBar/Views/PlexLibraryFilterPicker.swift
@@ -0,0 +1,152 @@
+import SwiftUI
+
+struct PlexLibraryFilterPicker: View {
+ @Environment(\.dismiss) private var dismiss
+
+ let filter: PlexLibraryFilterDefinition
+ @Bindable var browserStore: PlexBrowserStore
+ @Bindable var searchStore: PlexLibrarySearchStore
+ @State private var searchText = ""
+ @State private var selectedValueIDs: Set
+
+ init(
+ filter: PlexLibraryFilterDefinition,
+ browserStore: PlexBrowserStore,
+ searchStore: PlexLibrarySearchStore
+ ) {
+ self.filter = filter
+ self.browserStore = browserStore
+ self.searchStore = searchStore
+ _selectedValueIDs = State(
+ initialValue: Set(searchStore.selectedValues(for: filter).map(\.id))
+ )
+ }
+
+ var body: some View {
+ NavigationStack {
+ content
+ .navigationTitle(filter.title)
+ .searchable(
+ text: $searchText,
+ placement: .toolbar,
+ prompt: "Search \(filter.title)"
+ )
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel", action: dismiss.callAsFunction)
+ .keyboardShortcut(.cancelAction)
+ }
+
+ ToolbarItemGroup(placement: .confirmationAction) {
+ Button("Clear", action: clearSelection)
+ .disabled(selectedValueIDs.isEmpty)
+
+ Button(applyButtonTitle, action: applySelection)
+ .disabled(!browserStore.hasLoadedFilterValues(for: filter))
+ .keyboardShortcut(.defaultAction)
+ }
+ }
+ }
+ .frame(minWidth: 520, minHeight: 520)
+ .task(id: filter.id) {
+ await browserStore.loadFilterValues(for: filter)
+ }
+ }
+
+ @ViewBuilder
+ private var content: some View {
+ if let errorMessage = browserStore.filterValuesErrorMessage(for: filter),
+ values.isEmpty {
+ ContentUnavailableView {
+ Label("Couldn’t Load \(filter.title)", systemImage: "exclamationmark.triangle")
+ } description: {
+ Text(errorMessage)
+ } actions: {
+ Button("Try Again", action: retry)
+ }
+ } else if !browserStore.hasLoadedFilterValues(for: filter)
+ || (browserStore.isLoadingFilterValues(for: filter) && values.isEmpty) {
+ ProgressView("Loading \(filter.title)")
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else if values.isEmpty {
+ ContentUnavailableView("No \(filter.title) Values", systemImage: "line.3.horizontal.decrease")
+ } else if filteredValues.isEmpty {
+ ContentUnavailableView.search(text: normalizedSearchText)
+ } else {
+ List(filteredValues) { value in
+ Button {
+ toggle(value)
+ } label: {
+ HStack {
+ Text(value.title)
+ Spacer()
+ if selectedValueIDs.contains(value.id) {
+ Image(systemName: "checkmark")
+ .foregroundStyle(.tint)
+ }
+ }
+ .contentShape(.rect)
+ }
+ .buttonStyle(.plain)
+ .accessibilityValue(
+ selectedValueIDs.contains(value.id) ? "Selected" : "Not selected"
+ )
+ .accessibilityAddTraits(
+ selectedValueIDs.contains(value.id) ? .isSelected : []
+ )
+ }
+ .overlay(alignment: .top) {
+ if browserStore.isLoadingFilterValues(for: filter) {
+ ProgressView()
+ .progressViewStyle(.linear)
+ .accessibilityLabel("Refreshing \(filter.title)")
+ }
+ }
+ }
+ }
+
+ private var values: [PlexLibraryFilterValue] {
+ browserStore.filterValues(for: filter)
+ }
+
+ private var normalizedSearchText: String {
+ searchText.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ private var filteredValues: [PlexLibraryFilterValue] {
+ guard !normalizedSearchText.isEmpty else {
+ return values
+ }
+ return values.filter {
+ $0.title.localizedStandardContains(normalizedSearchText)
+ }
+ }
+
+ private var applyButtonTitle: String {
+ selectedValueIDs.isEmpty ? "Apply" : "Apply (\(selectedValueIDs.count))"
+ }
+
+ private func toggle(_ value: PlexLibraryFilterValue) {
+ if !selectedValueIDs.insert(value.id).inserted {
+ selectedValueIDs.remove(value.id)
+ }
+ }
+
+ private func clearSelection() {
+ selectedValueIDs.removeAll()
+ }
+
+ private func applySelection() {
+ searchStore.setSelectedValues(
+ values.filter { selectedValueIDs.contains($0.id) },
+ for: filter
+ )
+ dismiss()
+ }
+
+ private func retry() {
+ Task {
+ await browserStore.loadFilterValues(for: filter, forceRefresh: true)
+ }
+ }
+}
diff --git a/PlexBar/Views/PlexMainWindowView.swift b/PlexBar/Views/PlexMainWindowView.swift
new file mode 100644
index 0000000..2e0091d
--- /dev/null
+++ b/PlexBar/Views/PlexMainWindowView.swift
@@ -0,0 +1,752 @@
+import PlexModels
+import SwiftUI
+
+struct PlexMainWindowView: View {
+ @Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion
+ @Bindable var settingsStore: PlexSettingsStore
+ @Bindable var connectionStore: PlexConnectionStore
+ @Bindable var authStore: PlexAuthStore
+ @Bindable var sessionStore: PlexSessionStore
+ @Bindable var historyStore: PlexHistoryStore
+ @Bindable var libraryStore: PlexLibraryStore
+ @Bindable var browserStore: PlexBrowserStore
+ @Bindable var playerCoordinator: PlexPlayerCoordinator
+ @Bindable var navigationStore: PlexMainNavigationStore
+ @Bindable var downloadsStore: PlexDownloadsStore
+ @State private var libraryPresentationStore = PlexLibraryPresentationStore()
+ @State private var selectionBeforeGlobalSearch: PlexMainSection?
+ @FocusState private var isGlobalSearchFocused: Bool
+
+ var body: some View {
+ ZStack {
+ if let presentation = playerCoordinator.presentation {
+ PlexPlayerView(
+ presentation: presentation,
+ browserStore: browserStore,
+ settingsStore: settingsStore,
+ coordinator: playerCoordinator,
+ downloadsStore: downloadsStore
+ )
+ .id(presentation.id)
+ .transition(PlexMotion.surfaceTransition)
+ } else {
+ NavigationSplitView {
+ List(selection: sidebarSelection) {
+ Section {
+ navigationRow(.home)
+ }
+
+ if !libraryStore.libraries.isEmpty {
+ Section("Libraries") {
+ ForEach(libraryStore.libraries) { library in
+ Label(library.title, systemImage: library.type.symbolName)
+ .tag(PlexMainSection.library(library.id))
+ }
+ }
+ }
+
+ Section("Media") {
+ navigationRow(.downloads)
+ navigationRow(.collections)
+ navigationRow(.playlists)
+ }
+
+ Section("Server") {
+ navigationRow(.activity, badge: sessionStore.activeStreamCount)
+ navigationRow(.history)
+ navigationRow(.users)
+ }
+ }
+ .modifier(PlexActivityVisibility(store: sessionStore))
+ .navigationTitle("PlexBar")
+ .navigationSplitViewColumnWidth(min: 190, ideal: 220, max: 280)
+ .searchable(
+ text: globalSearchText,
+ placement: .sidebar,
+ prompt: "Search"
+ )
+ .searchFocused($isGlobalSearchFocused)
+ } detail: {
+ ZStack {
+ detail
+ .id(detailPresentationIdentity)
+ .transition(PlexMotion.surfaceTransition)
+ }
+ .animation(
+ PlexMotion.surfaceAnimation(reduceMotion: accessibilityReduceMotion),
+ value: detailPresentationIdentity
+ )
+ }
+ .navigationSplitViewStyle(.balanced)
+ .transition(PlexMotion.surfaceTransition)
+ }
+ }
+ .animation(
+ PlexMotion.surfaceAnimation(reduceMotion: accessibilityReduceMotion),
+ value: playerCoordinator.presentation?.id
+ )
+ .focusedSceneValue(
+ \.plexSearchCommand,
+ PlexFocusedCommandAction(
+ title: "Search All Libraries",
+ isEnabled: playerCoordinator.presentation == nil
+ && settingsStore.hasValidConfiguration
+ ) {
+ isGlobalSearchFocused = true
+ }
+ )
+ .focusedSceneValue(
+ \.plexRefreshCommand,
+ PlexFocusedCommandAction(
+ title: "Refresh All",
+ isEnabled: playerCoordinator.presentation == nil
+ && settingsStore.hasValidConfiguration,
+ perform: refreshAllData
+ )
+ )
+ .task {
+ await start()
+ }
+ .onChange(of: libraryStore.libraries.map(\.id), initial: true) { _, libraryIDs in
+ libraryPresentationStore.synchronize(libraryIDs: libraryIDs)
+ }
+ .onChange(of: connectionStore.accountCacheScope) { _, _ in
+ resetStateForAccountChange()
+ Task {
+ await downloadsStore.resumePendingJobs()
+ await downloadsStore.synchronizeOfflineProgress()
+ }
+ }
+ .onChange(of: browserStore.globalSearchStore.normalizedQuery) { oldQuery, newQuery in
+ if oldQuery.isEmpty, !newQuery.isEmpty {
+ selectionBeforeGlobalSearch = navigationStore.selection
+ navigationStore.selection = nil
+ } else if !oldQuery.isEmpty, newQuery.isEmpty {
+ browserStore.globalSearchStore.reset()
+ if navigationStore.selection == nil {
+ navigationStore.selection = selectionBeforeGlobalSearch ?? .home
+ }
+ selectionBeforeGlobalSearch = nil
+ }
+ }
+ .onChange(of: browserStore.globalSearchStore.navigationPath) { _, navigationPath in
+ if !navigationPath.isEmpty {
+ isGlobalSearchFocused = false
+ }
+ }
+ .toolbar {
+ if playerCoordinator.presentation == nil, !usesDestinationToolbar {
+ ToolbarItem {
+ Button("Refresh", systemImage: "arrow.clockwise", action: refreshAllData)
+ .disabled(!settingsStore.hasValidConfiguration)
+ }
+ }
+ }
+ }
+
+ private func navigationRow(_ section: PlexMainSection, badge: Int = 0) -> some View {
+ Label(section.title, systemImage: section.systemImage)
+ .badge(badge)
+ .tag(section)
+ }
+
+ private var sidebarSelection: Binding {
+ Binding(
+ get: { navigationStore.selection },
+ set: { selection in
+ navigationStore.selection = selection
+ if selection != nil {
+ selectionBeforeGlobalSearch = nil
+ }
+ dismissGlobalSearch()
+ }
+ )
+ }
+
+ private var globalSearchText: Binding {
+ Binding(
+ get: { browserStore.globalSearchStore.text },
+ set: { browserStore.globalSearchStore.text = $0 }
+ )
+ }
+
+ private var presentsGlobalSearch: Bool {
+ settingsStore.hasValidConfiguration
+ && !browserStore.globalSearchStore.normalizedQuery.isEmpty
+ }
+
+ private var detailPresentationIdentity: DetailPresentationIdentity {
+ if presentsGlobalSearch {
+ return .globalSearch
+ }
+ return .section(navigationStore.selection ?? .home)
+ }
+
+ @ViewBuilder
+ private var detail: some View {
+ if presentsGlobalSearch {
+ PlexGlobalSearchNavigationHost(
+ searchStore: browserStore.globalSearchStore,
+ browserStore: browserStore,
+ historyStore: historyStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator
+ )
+ } else if navigationStore.selection == .downloads {
+ PlexDownloadsView(
+ downloadsStore: downloadsStore,
+ playerCoordinator: playerCoordinator
+ )
+ } else if !settingsStore.hasLoadedCredentials {
+ if let errorMessage = settingsStore.credentialLoadingErrorMessage,
+ !settingsStore.isLoadingCredentials {
+ ContentUnavailableView {
+ Label("Couldn’t Access Keychain", systemImage: "key.slash")
+ } description: {
+ Text(errorMessage)
+ } actions: {
+ Button("Try Again") {
+ Task { await start() }
+ }
+ }
+ } else {
+ ProgressView("Loading Account…")
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ } else if !settingsStore.hasAuthenticatedAccount {
+ PlexAccountRequiredView(
+ settingsStore: settingsStore,
+ authStore: authStore
+ )
+ } else if !settingsStore.hasValidConfiguration {
+ PlexServerRequiredView(authStore: authStore)
+ } else {
+ switch navigationStore.selection ?? .home {
+ case .home:
+ PlexMediaNavigationStack(
+ path: $navigationStore.homeNavigationPath,
+ browserStore: browserStore,
+ historyStore: historyStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator
+ ) {
+ PlexHomeView(
+ browserStore: browserStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator
+ )
+ }
+ case .downloads:
+ PlexDownloadsView(
+ downloadsStore: downloadsStore,
+ playerCoordinator: playerCoordinator
+ )
+ case .library(let libraryID):
+ if let library = libraryStore.libraries.first(where: { $0.id == libraryID }),
+ let presentationState = libraryPresentationStore.state(for: libraryID) {
+ PlexLibraryNavigationHost(
+ library: library,
+ presentationState: presentationState,
+ browserStore: browserStore,
+ historyStore: historyStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator
+ )
+ } else {
+ ContentUnavailableView("Library Unavailable", systemImage: "books.vertical")
+ }
+ case .collections:
+ PlexMediaNavigationStack(
+ path: $navigationStore.collectionsNavigationPath,
+ browserStore: browserStore,
+ historyStore: historyStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator
+ ) {
+ PlexCollectionsView(
+ libraries: libraryStore.libraries,
+ browserStore: browserStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator
+ )
+ }
+ case .playlists:
+ PlexMediaNavigationStack(
+ path: $navigationStore.playlistsNavigationPath,
+ browserStore: browserStore,
+ historyStore: historyStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator
+ ) {
+ PlexPlaylistsView(
+ browserStore: browserStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator
+ )
+ }
+ case .activity:
+ PlexActivityView(
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ sessionStore: sessionStore
+ )
+ case .history:
+ PlexMediaNavigationStack(
+ path: $navigationStore.historyNavigationPath,
+ browserStore: browserStore,
+ historyStore: historyStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator
+ ) {
+ ScrollView {
+ HistoryDashboardView(
+ settingsStore: settingsStore,
+ serverURL: connectionStore.resolvedServerURL,
+ historyStore: historyStore,
+ allowsMediaNavigation: true
+ )
+ .scenePadding()
+ }
+ .navigationTitle("History")
+ .focusedSceneValue(
+ \.plexRefreshCommand,
+ PlexFocusedCommandAction(
+ title: "Refresh History",
+ isEnabled: !historyStore.isLoading,
+ perform: historyStore.refreshNow
+ )
+ )
+ .toolbar {
+ ToolbarItem {
+ Button("Refresh History", systemImage: "arrow.clockwise", action: historyStore.refreshNow)
+ .disabled(historyStore.isLoading)
+ }
+ }
+ }
+ case .users:
+ ScrollView {
+ UsersDashboardView(
+ settingsStore: settingsStore,
+ serverURL: connectionStore.resolvedServerURL,
+ historyStore: historyStore
+ )
+ .scenePadding()
+ }
+ .navigationTitle("Users")
+ }
+ }
+ }
+
+ private func refreshAllData() {
+ guard settingsStore.hasLoadedCredentials else {
+ return
+ }
+ sessionStore.refreshNow()
+ historyStore.refreshNow()
+ libraryStore.refreshNow()
+ }
+
+ private func start() async {
+ await settingsStore.loadCredentials()
+ guard settingsStore.hasLoadedCredentials else {
+ return
+ }
+ await authStore.credentialsDidLoad()
+ await downloadsStore.resumePendingJobs()
+ await downloadsStore.synchronizeOfflineProgress()
+ refreshAllData()
+ await browserStore.loadHomeHubs()
+ }
+
+ private var usesDestinationToolbar: Bool {
+ if presentsGlobalSearch {
+ return true
+ }
+ switch navigationStore.selection {
+ case .home, .downloads, .library, .collections, .playlists, .history:
+ return true
+ default:
+ return false
+ }
+ }
+
+ private func dismissGlobalSearch() {
+ guard isGlobalSearchFocused
+ || !browserStore.globalSearchStore.normalizedQuery.isEmpty
+ || !browserStore.globalSearchStore.navigationPath.isEmpty else {
+ return
+ }
+ isGlobalSearchFocused = false
+ browserStore.globalSearchStore.reset()
+ }
+
+ private func resetStateForAccountChange() {
+ playerCoordinator.clear()
+ navigationStore.resetForServerChange()
+ browserStore.resetServerScopedState()
+ libraryStore.resetServerScopedState()
+ historyStore.resetServerScopedState()
+ sessionStore.didChangeConfiguration()
+ libraryPresentationStore.removeAll()
+ }
+}
+
+private enum DetailPresentationIdentity: Hashable {
+ case globalSearch
+ case section(PlexMainSection)
+}
+
+private struct PlexGlobalSearchNavigationHost: View {
+ @Bindable var searchStore: PlexGlobalSearchStore
+ @Bindable var browserStore: PlexBrowserStore
+ @Bindable var historyStore: PlexHistoryStore
+ @Bindable var settingsStore: PlexSettingsStore
+ @Bindable var connectionStore: PlexConnectionStore
+ @Bindable var playerCoordinator: PlexPlayerCoordinator
+
+ var body: some View {
+ PlexMediaNavigationStack(
+ path: $searchStore.navigationPath,
+ browserStore: browserStore,
+ historyStore: historyStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator
+ ) {
+ PlexGlobalSearchView(
+ searchStore: searchStore,
+ browserStore: browserStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator
+ )
+ }
+ }
+}
+
+private struct PlexLibraryNavigationHost: View {
+ let library: PlexLibrary
+ @Bindable var presentationState: PlexLibraryPresentationState
+ @Bindable var browserStore: PlexBrowserStore
+ @Bindable var historyStore: PlexHistoryStore
+ @Bindable var settingsStore: PlexSettingsStore
+ @Bindable var connectionStore: PlexConnectionStore
+ @Bindable var playerCoordinator: PlexPlayerCoordinator
+
+ var body: some View {
+ PlexMediaNavigationStack(
+ path: $presentationState.navigationPath,
+ browserStore: browserStore,
+ historyStore: historyStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator
+ ) {
+ PlexLibraryBrowserView(
+ library: library,
+ searchStore: presentationState.searchStore,
+ scrollPosition: $presentationState.scrollPosition,
+ browserStore: browserStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator
+ )
+ }
+ }
+}
+
+private struct PlexMediaNavigationStack: View {
+ @Binding var path: [PlexNavigationRoute]
+ @Bindable var browserStore: PlexBrowserStore
+ @Bindable var historyStore: PlexHistoryStore
+ @Bindable var settingsStore: PlexSettingsStore
+ @Bindable var connectionStore: PlexConnectionStore
+ @Bindable var playerCoordinator: PlexPlayerCoordinator
+ @ViewBuilder let root: Root
+
+ init(
+ path: Binding<[PlexNavigationRoute]>,
+ browserStore: PlexBrowserStore,
+ historyStore: PlexHistoryStore,
+ settingsStore: PlexSettingsStore,
+ connectionStore: PlexConnectionStore,
+ playerCoordinator: PlexPlayerCoordinator,
+ @ViewBuilder root: () -> Root
+ ) {
+ _path = path
+ self.browserStore = browserStore
+ self.historyStore = historyStore
+ self.settingsStore = settingsStore
+ self.connectionStore = connectionStore
+ self.playerCoordinator = playerCoordinator
+ self.root = root()
+ }
+
+ var body: some View {
+ NavigationStack(path: $path) {
+ root
+ .navigationDestination(for: PlexNavigationRoute.self) { route in
+ switch route {
+ case .media(let mediaRoute):
+ PlexResolvedMediaDestinationView(
+ route: mediaRoute,
+ browserStore: browserStore,
+ historyStore: historyStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator
+ )
+ case .person(let personRoute):
+ PlexPersonDetailsView(
+ route: personRoute,
+ browserStore: browserStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator
+ )
+ case .homeHub(let hubRoute):
+ if let hub = browserStore.homeHub(for: hubRoute) {
+ PlexHomeHubItemsView(
+ hub: hub,
+ browserStore: browserStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator
+ )
+ } else {
+ ContentUnavailableView("Home Section Unavailable", systemImage: "rectangle.stack")
+ }
+ case .searchHub(let hubRoute):
+ if let hub = browserStore.globalSearchStore.hub(for: hubRoute) {
+ PlexSearchHubItemsView(
+ hub: hub,
+ searchStore: browserStore.globalSearchStore,
+ browserStore: browserStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator
+ )
+ } else {
+ ContentUnavailableView("Search Section Unavailable", systemImage: "rectangle.stack")
+ }
+ case .relatedHub(let hubRoute):
+ if let hub = browserStore.relatedHub(for: hubRoute) {
+ PlexRelatedHubItemsView(
+ route: hubRoute,
+ hub: hub,
+ browserStore: browserStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator
+ )
+ } else {
+ ContentUnavailableView("Related Section Unavailable", systemImage: "rectangle.stack")
+ }
+ }
+ }
+ }
+ }
+}
+
+private struct PlexResolvedMediaDestinationView: View {
+ let route: PlexMediaRoute
+ @Bindable var browserStore: PlexBrowserStore
+ @Bindable var historyStore: PlexHistoryStore
+ @Bindable var settingsStore: PlexSettingsStore
+ @Bindable var connectionStore: PlexConnectionStore
+ @Bindable var playerCoordinator: PlexPlayerCoordinator
+ @State private var item: PlexMediaItem?
+ @State private var refreshesInitialMetadata = true
+ @State private var errorMessage: String?
+ @State private var resolutionID = UUID()
+
+ var body: some View {
+ Group {
+ if let item {
+ PlexMediaDestinationView(
+ initialItem: item,
+ browserStore: browserStore,
+ historyStore: historyStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator,
+ refreshesInitialMetadata: refreshesInitialMetadata
+ )
+ } else if let errorMessage {
+ ContentUnavailableView {
+ Label("Couldn’t Load Item", systemImage: "exclamationmark.triangle")
+ } description: {
+ Text(errorMessage)
+ } actions: {
+ Button("Try Again", action: retry)
+ }
+ } else {
+ ProgressView("Loading…")
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .accessibilityLabel("Loading media details")
+ }
+ }
+ .task(id: resolutionID) {
+ await resolve()
+ }
+ }
+
+ private func retry() {
+ resolutionID = UUID()
+ }
+
+ private func resolve() async {
+ errorMessage = nil
+
+ do {
+ if let cachedItem = browserStore.item(for: route) {
+ refreshesInitialMetadata = true
+ item = cachedItem
+ } else {
+ let resolvedItem = try await browserStore.resolveItem(for: route)
+ refreshesInitialMetadata = false
+ item = resolvedItem
+ }
+ } catch {
+ guard !Task.isCancelled else {
+ return
+ }
+ errorMessage = error.localizedDescription
+ }
+ }
+}
+
+private struct PlexAccountRequiredView: View {
+ @Bindable var settingsStore: PlexSettingsStore
+ @Bindable var authStore: PlexAuthStore
+
+ var body: some View {
+ ContentUnavailableView {
+ Label("Not Signed In", systemImage: "person.crop.circle")
+ } description: {
+ if let progressMessage = authStore.signInProgressMessage {
+ VStack(spacing: 8) {
+ ProgressView()
+ .controlSize(.small)
+ Text(progressMessage)
+ }
+ } else if let errorMessage = authStore.errorMessage
+ ?? settingsStore.credentialPersistenceErrorMessage {
+ Text(errorMessage)
+ }
+ } actions: {
+ if authStore.canCancelSignIn {
+ Button("Cancel", role: .cancel) {
+ authStore.cancelSignIn()
+ }
+ } else if !authStore.isAuthenticating {
+ Button("Sign In to Plex") {
+ authStore.startSignIn()
+ }
+ .buttonStyle(.borderedProminent)
+ }
+ }
+ .navigationTitle("PlexBar")
+ }
+}
+
+private struct PlexServerRequiredView: View {
+ @Bindable var authStore: PlexAuthStore
+
+ var body: some View {
+ ContentUnavailableView {
+ Label("No Server Selected", systemImage: "server.rack")
+ } actions: {
+ if authStore.isLoadingServers {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Button("Refresh Servers") {
+ Task {
+ await authStore.refreshServers(autoSelectStoredServer: true)
+ }
+ }
+ }
+ }
+ .navigationTitle("PlexBar")
+ }
+}
+
+private struct PlexActivityView: View {
+ @Bindable var settingsStore: PlexSettingsStore
+ @Bindable var connectionStore: PlexConnectionStore
+ @Bindable var sessionStore: PlexSessionStore
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 20) {
+ if sessionStore.lastHydratedAt != nil {
+ HStack(alignment: .top) {
+ PlexActivitySummaryView(
+ summary: sessionStore.activitySummary,
+ isStale: sessionStore.activityErrorMessage != nil
+ )
+ if sessionStore.isLoading {
+ ProgressView().controlSize(.small)
+ }
+ }
+ }
+
+ if let message = sessionStore.activityErrorMessage {
+ InlineWarningBanner(message: message)
+ }
+
+ if sessionStore.lastHydratedAt == nil {
+ if sessionStore.activityErrorMessage != nil {
+ ContentUnavailableView {
+ Label("Activity Unavailable", systemImage: "exclamationmark.triangle")
+ } actions: {
+ Button("Refresh") { sessionStore.refreshNow() }
+ }
+ } else {
+ ProgressView("Loading activity…")
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ } else if sessionStore.sessions.isEmpty {
+ ContentUnavailableView("No Active Streams", systemImage: "play.slash")
+ } else {
+ LazyVGrid(
+ columns: [GridItem(.adaptive(minimum: 360), spacing: 16, alignment: .top)],
+ alignment: .leading,
+ spacing: 16
+ ) {
+ ForEach(sessionStore.sessions) { session in
+ StreamCardView(
+ session: session,
+ sessionStore: sessionStore,
+ onRequestTerminate: { _ in },
+ isShowingTerminatePrompt: false,
+ terminateMessage: .constant(""),
+ onCancelTerminate: {},
+ onConfirmTerminate: { _ in },
+ serverURL: connectionStore.resolvedServerURL,
+ settingsStore: settingsStore,
+ snapshotDate: sessionStore.lastUpdated,
+ resolvedLocation: sessionStore.resolvedLocation(for: session)
+ )
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+ .scenePadding()
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .navigationTitle("Activity")
+ .modifier(PlexActivityVisibility(store: sessionStore))
+ }
+}
diff --git a/PlexBar/Views/PlexMediaDescriptionView.swift b/PlexBar/Views/PlexMediaDescriptionView.swift
new file mode 100644
index 0000000..daeedda
--- /dev/null
+++ b/PlexBar/Views/PlexMediaDescriptionView.swift
@@ -0,0 +1,89 @@
+import SwiftUI
+
+struct PlexMediaDescriptionView: View {
+ let title: String
+ let summary: String
+ @State private var availableWidth: CGFloat = 0
+ @State private var presentsDescription = false
+ @ScaledMetric(relativeTo: .body) private var fontScale = 1.0
+
+ private var textFont: NSFont {
+ let font = NSFont.preferredFont(forTextStyle: .body)
+ return font.withSize(font.pointSize * fontScale)
+ }
+
+ var body: some View {
+ let lineBreak = PlexDescriptionLineBreak(summary: summary, width: availableWidth, font: textFont)
+
+ Group {
+ if let lineBreak {
+ VStack(alignment: .leading, spacing: 3) {
+ Text(lineBreak.firstLine)
+ .lineLimit(1, reservesSpace: true)
+ .fixedSize(horizontal: false, vertical: true)
+
+ HStack(alignment: .firstTextBaseline, spacing: 6) {
+ Text(lineBreak.remainingText)
+ .lineLimit(1, reservesSpace: true)
+ .truncationMode(.tail)
+
+ Button("MORE") { presentsDescription = true }
+ .buttonStyle(.borderless)
+ .foregroundStyle(.secondary)
+ .fixedSize()
+ }
+ }
+ } else {
+ Text(summary)
+ .lineSpacing(3)
+ .lineLimit(2)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ .font(Font(textFont))
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .onGeometryChange(for: CGFloat.self) { $0.size.width } action: {
+ availableWidth = $0
+ }
+ .sheet(isPresented: $presentsDescription) {
+ PlexFullDescriptionSheet(title: title, summary: summary)
+ }
+ .onChange(of: summary) {
+ presentsDescription = false
+ }
+ }
+}
+
+private struct PlexFullDescriptionSheet: View {
+ @Environment(\.dismiss) private var dismiss
+ let title: String
+ let summary: String
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 20) {
+ Text(title)
+ .font(.headline)
+ .accessibilityAddTraits(.isHeader)
+
+ ScrollView {
+ Text(summary)
+ .font(.body)
+ .lineSpacing(3)
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(maxHeight: 360)
+
+ HStack {
+ Spacer()
+ Button("Done") { dismiss() }
+ .keyboardShortcut(.cancelAction)
+ }
+ }
+ .foregroundStyle(.primary)
+ .padding(24)
+ .frame(width: 560)
+ }
+}
diff --git a/PlexBar/Views/PlexMediaDestinationView.swift b/PlexBar/Views/PlexMediaDestinationView.swift
new file mode 100644
index 0000000..2c38791
--- /dev/null
+++ b/PlexBar/Views/PlexMediaDestinationView.swift
@@ -0,0 +1,832 @@
+import PlexModels
+import SwiftUI
+
+struct PlexMediaDestinationView: View {
+ let initialItem: PlexMediaItem
+ @Bindable var browserStore: PlexBrowserStore
+ @Bindable var historyStore: PlexHistoryStore
+ @Bindable var settingsStore: PlexSettingsStore
+ @Bindable var connectionStore: PlexConnectionStore
+ @Bindable var playerCoordinator: PlexPlayerCoordinator
+ let refreshesInitialMetadata: Bool
+
+ var body: some View {
+ if initialItem.type?.lowercased() == "show" {
+ PlexTVShowDetailView(
+ initialShow: initialItem,
+ browserStore: browserStore,
+ historyStore: historyStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator,
+ refreshesInitialMetadata: refreshesInitialMetadata
+ )
+ } else if initialItem.type?.lowercased() == "episode",
+ initialItem.grandparentRatingKey?.nilIfBlank != nil {
+ PlexTVEpisodeDestinationView(
+ initialEpisode: initialItem,
+ browserStore: browserStore,
+ historyStore: historyStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator,
+ refreshesInitialMetadata: refreshesInitialMetadata
+ )
+ } else if initialItem.hasChildren {
+ PlexMediaHierarchyView(
+ initialItem: initialItem,
+ browserStore: browserStore,
+ historyStore: historyStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator,
+ refreshesInitialMetadata: refreshesInitialMetadata
+ )
+ } else {
+ PlexMediaDetailsView(
+ initialItem: initialItem,
+ browserStore: browserStore,
+ historyStore: historyStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator,
+ refreshesInitialMetadata: refreshesInitialMetadata
+ )
+ }
+ }
+}
+
+private struct PlexTVEpisodeDestinationView: View {
+ let initialEpisode: PlexMediaItem
+ @Bindable var browserStore: PlexBrowserStore
+ @Bindable var historyStore: PlexHistoryStore
+ @Bindable var settingsStore: PlexSettingsStore
+ @Bindable var connectionStore: PlexConnectionStore
+ @Bindable var playerCoordinator: PlexPlayerCoordinator
+ let refreshesInitialMetadata: Bool
+ @State private var show: PlexMediaItem?
+ @State private var errorMessage: String?
+
+ var body: some View {
+ Group {
+ if let show {
+ PlexTVShowDetailView(
+ initialShow: show,
+ initialEpisode: initialEpisode,
+ browserStore: browserStore,
+ historyStore: historyStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator,
+ refreshesInitialMetadata: refreshesInitialMetadata
+ )
+ } else if let errorMessage {
+ ContentUnavailableView {
+ Label("Couldn’t Load Show", systemImage: "exclamationmark.triangle")
+ } description: {
+ Text(errorMessage)
+ }
+ } else {
+ ProgressView("Loading show…")
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+ .task(id: initialEpisode.ratingKey) {
+ await resolveShow()
+ }
+ }
+
+ private func resolveShow() async {
+ guard let ratingKey = initialEpisode.grandparentRatingKey?.nilIfBlank,
+ let route = PlexMediaRoute(ratingKey: ratingKey) else {
+ errorMessage = "Plex did not identify the show for this episode."
+ return
+ }
+
+ do {
+ let resolvedShow = try await browserStore.resolveItem(for: route)
+ guard !Task.isCancelled else { return }
+ show = resolvedShow
+ errorMessage = nil
+ } catch {
+ guard !Task.isCancelled else { return }
+ errorMessage = error.localizedDescription
+ }
+ }
+}
+
+private struct PlexMediaHierarchyView: View {
+ @Environment(PlexDownloadsStore.self) private var downloadsStore
+ @Bindable var browserStore: PlexBrowserStore
+ @Bindable var historyStore: PlexHistoryStore
+ @Bindable var settingsStore: PlexSettingsStore
+ @Bindable var connectionStore: PlexConnectionStore
+ @Bindable var playerCoordinator: PlexPlayerCoordinator
+ let refreshesInitialMetadata: Bool
+ @State private var item: PlexMediaItem
+ @State private var hierarchyItem: PlexMediaItem
+ @State private var isPreparingPlayback = false
+ @State private var playbackErrorMessage: String?
+ @State private var presentsAutomaticDownload = false
+
+ init(
+ initialItem: PlexMediaItem,
+ browserStore: PlexBrowserStore,
+ historyStore: PlexHistoryStore,
+ settingsStore: PlexSettingsStore,
+ connectionStore: PlexConnectionStore,
+ playerCoordinator: PlexPlayerCoordinator,
+ refreshesInitialMetadata: Bool
+ ) {
+ self.browserStore = browserStore
+ self.historyStore = historyStore
+ self.settingsStore = settingsStore
+ self.connectionStore = connectionStore
+ self.playerCoordinator = playerCoordinator
+ self.refreshesInitialMetadata = refreshesInitialMetadata
+ _item = State(initialValue: initialItem)
+ _hierarchyItem = State(initialValue: initialItem)
+ }
+
+ var body: some View {
+ ZStack(alignment: .topLeading) {
+ detailBackdrop
+ .ignoresSafeArea()
+
+ ScrollView {
+ VStack(alignment: .leading, spacing: 0) {
+ PlexMediaOverview(
+ item: item,
+ settingsStore: settingsStore,
+ serverURL: connectionStore.resolvedServerURL,
+ showsPlaybackControl: hierarchyItem.continuousPlayQueueUsesOnDeck,
+ isPlaybackEnabled: hierarchyItem.supportsHierarchyPlayback,
+ isPreparingPlayback: isPreparingPlayback,
+ preparePlayback: preparePlayback,
+ showsAutomaticDownloadControl: downloadsStore
+ .canCreateAutomaticDownloadRule(for: item),
+ prepareAutomaticDownload: {
+ presentsAutomaticDownload = true
+ }
+ )
+
+ VStack(alignment: .leading, spacing: 30) {
+ childrenSection
+
+ PlexCastAndCrewView(
+ item: item,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore
+ )
+
+ PlexMediaMetadataView(item: item)
+ .frame(maxWidth: 980, alignment: .leading)
+
+ if hasVisibleMediaHistory {
+ PlexMediaHistoryView(
+ item: item,
+ historyStore: historyStore,
+ settingsStore: settingsStore,
+ serverURL: connectionStore.resolvedServerURL
+ )
+ }
+
+ if hasVisibleExtras {
+ PlexMediaExtrasView(
+ item: item,
+ browserStore: browserStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator
+ )
+ }
+
+ if hasVisibleRelatedContent {
+ PlexRelatedContentView(
+ item: item,
+ browserStore: browserStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator
+ )
+ }
+ }
+ .frame(maxWidth: 1_100, alignment: .leading)
+ .scenePadding()
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+ .navigationTitle(item.title)
+ .focusedSceneValue(
+ \.plexRefreshCommand,
+ PlexFocusedCommandAction(
+ title: "Reload \(item.title)",
+ isEnabled: !isReloadingContent,
+ perform: refresh
+ )
+ )
+ .task(id: item.id) {
+ let requestedItem = item
+ async let capabilityLoad: Void = browserStore.loadLibraryProviderCapabilities()
+ async let discoveryLoad: Void = loadDiscoveryContent(for: requestedItem)
+ async let historyLoad: Void = historyStore.loadMediaHistory(for: requestedItem)
+ if refreshesInitialMetadata, item.supportsLibraryMetadataDetails {
+ let details = await browserStore.details(for: item)
+ if details != item {
+ hierarchyItem = hierarchyItem.hierarchyRequestItem(afterRefreshingWith: details)
+ item = details
+ }
+ }
+ await browserStore.loadChildren(of: hierarchyItem)
+ _ = await (capabilityLoad, discoveryLoad, historyLoad)
+ }
+ .toolbar {
+ ToolbarItemGroup {
+ PlexWatchedStateButton(item: $item, browserStore: browserStore)
+ PlexPersonalRatingMenu(item: $item, browserStore: browserStore)
+ PlexPlaybackQueueMenu(item: item, playerCoordinator: playerCoordinator)
+ PlexMetadataRefreshButton(item: item, browserStore: browserStore)
+ Button("Reload \(item.title)", systemImage: "arrow.clockwise", action: refresh)
+ .disabled(isReloadingContent)
+ }
+ }
+ .sheet(isPresented: $presentsAutomaticDownload) {
+ PlexAutomaticDownloadSheet(item: item, downloadsStore: downloadsStore)
+ }
+ .alert(
+ "Playback Error",
+ isPresented: Binding(
+ get: { playbackErrorMessage != nil },
+ set: { isPresented in
+ if !isPresented {
+ playbackErrorMessage = nil
+ }
+ }
+ )
+ ) {
+ Button("OK") {}
+ } message: {
+ Text(playbackErrorMessage ?? "Unknown playback error.")
+ }
+ }
+
+ @ViewBuilder
+ private var detailBackdrop: some View {
+ if item.usesCinematicHierarchyHero {
+ Color(nsColor: .windowBackgroundColor)
+ } else {
+ PlexArtworkBackdrop(
+ primaryImageURL: backdropPosterURL,
+ token: settingsStore.trimmedServerToken,
+ clientContext: clientContext
+ )
+ }
+ }
+
+ private var children: [PlexMediaItem] {
+ browserStore.children(of: hierarchyItem)
+ }
+
+ private var hasVisibleMediaHistory: Bool {
+ historyStore.mediaHistoryPresentation(for: item)?.isVisible == true
+ }
+
+ private var hasVisibleExtras: Bool {
+ item.supportsLibraryMetadataDetails
+ && (browserStore.isLoadingMediaExtras(for: item)
+ || browserStore.mediaExtrasErrorMessage(for: item) != nil
+ || !browserStore.mediaExtras(for: item).isEmpty)
+ }
+
+ private var hasVisibleRelatedContent: Bool {
+ item.supportsLibraryMetadataDetails
+ && (browserStore.isLoadingRelatedContent(for: item)
+ || browserStore.relatedContentErrorMessage(for: item) != nil
+ || !browserStore.relatedHubs(for: item).isEmpty)
+ }
+
+ private var isReloadingContent: Bool {
+ browserStore.isLoadingChildren(of: hierarchyItem)
+ || browserStore.detailDiscoveryPresentation(for: item).isLoading
+ || historyStore.mediaHistoryPresentation(for: item)?.isLoading == true
+ }
+
+ private var backdropPosterURL: URL? {
+ guard let serverURL = connectionStore.resolvedServerURL else {
+ return nil
+ }
+ return PlexURLBuilder.mediaURL(
+ serverURL: serverURL,
+ path: item.art?.nilIfBlank ?? item.posterArtworkPath
+ )
+ }
+
+ private var clientContext: PlexClientContext {
+ PlexClientContext(clientIdentifier: settingsStore.clientIdentifier)
+ }
+
+ private var childrenSectionTitle: String {
+ switch hierarchyItem.type?.lowercased() {
+ case "show":
+ hierarchyItem.skipChildren == true ? "Episodes" : "Seasons"
+ case "season":
+ "Episodes"
+ case "artist":
+ "Albums"
+ case "album":
+ "Tracks"
+ case "photoalbum":
+ "Photos"
+ case "playlistfolder":
+ "Playlists"
+ default:
+ "Items"
+ }
+ }
+
+ private func refresh() {
+ Task {
+ let requestedItem = item
+ async let childrenLoad: Void = browserStore.loadChildren(
+ of: hierarchyItem,
+ forceRefresh: true
+ )
+ async let discoveryLoad: Void = loadDiscoveryContent(
+ for: requestedItem,
+ forceRefresh: true
+ )
+ async let historyLoad: Void = historyStore.loadMediaHistory(
+ for: requestedItem,
+ forceRefresh: true
+ )
+ _ = await (childrenLoad, discoveryLoad, historyLoad)
+ }
+ }
+
+ private func preparePlayback() {
+ guard hierarchyItem.supportsHierarchyPlayback, !isPreparingPlayback else {
+ return
+ }
+
+ isPreparingPlayback = true
+ Task {
+ defer { isPreparingPlayback = false }
+
+ do {
+ let queue = try await browserStore.continuousPlayQueue(for: hierarchyItem)
+ let selectedItem = try await browserStore.refreshedPlayableDetails(
+ for: queue.currentItem
+ )
+ guard selectedItem.defaultPlaybackSource != nil else {
+ throw PlexAPIError.noPlayableMedia
+ }
+
+ let videoQuality = settingsStore.videoQuality(for: activeConnectionKind)
+ let plan = try await browserStore.playbackPlan(
+ for: selectedItem,
+ videoQuality: videoQuality
+ )
+ playerCoordinator.present(PlexPlaybackPresentation(
+ item: selectedItem,
+ plan: plan,
+ queue: queue,
+ videoQuality: videoQuality,
+ serverIdentifier: connectionStore.activeConnection?.serverID
+ ?? settingsStore.selectedServerIdentifier?.nilIfBlank
+ ))
+ } catch {
+ playbackErrorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ private var activeConnectionKind: PlexConnectionKind? {
+ connectionStore.activeConnection?.kind ?? settingsStore.cachedConnectionKind
+ }
+
+ @ViewBuilder
+ private var childrenSection: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack(alignment: .firstTextBaseline, spacing: 12) {
+ Text(childrenSectionTitle)
+ .font(.title2.weight(.semibold))
+ .accessibilityAddTraits(.isHeader)
+
+ Spacer(minLength: 12)
+
+ if !children.isEmpty {
+ Text(children.count, format: .number)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ GroupBox {
+ if hierarchyItem.childrenPath == nil {
+ compactUnavailableContent(
+ title: "\(childrenSectionTitle) Unavailable",
+ message: "Plex did not provide a path for this item.",
+ symbol: "rectangle.stack.badge.exclamationmark",
+ retry: nil
+ )
+ } else if browserStore.isLoadingChildren(of: hierarchyItem) && children.isEmpty {
+ ProgressView("Loading \(childrenSectionTitle)…")
+ .frame(maxWidth: .infinity, minHeight: 120)
+ .accessibilityLabel("Loading \(childrenSectionTitle)")
+ } else if let errorMessage = browserStore.childrenErrorMessage(for: hierarchyItem), children.isEmpty {
+ compactUnavailableContent(
+ title: "Couldn’t Load \(childrenSectionTitle)",
+ message: errorMessage,
+ symbol: "exclamationmark.triangle",
+ retry: refresh
+ )
+ } else if children.isEmpty {
+ compactUnavailableContent(
+ title: "No \(childrenSectionTitle)",
+ message: nil,
+ symbol: "rectangle.stack",
+ retry: nil
+ )
+ } else {
+ LazyVStack(spacing: 0) {
+ ForEach(Array(children.enumerated()), id: \.element.id) { index, child in
+ NavigationLink(value: PlexNavigationRoute.media(PlexMediaRoute(item: child))) {
+ PlexMediaChildRow(
+ item: child,
+ settingsStore: settingsStore,
+ serverURL: connectionStore.resolvedServerURL
+ )
+ }
+ .buttonStyle(.plain)
+ .plexMediaContextMenu(
+ for: child,
+ in: item,
+ browserStore: browserStore,
+ playerCoordinator: playerCoordinator
+ )
+ .task {
+ await browserStore.loadMoreChildrenIfNeeded(
+ of: hierarchyItem,
+ currentItem: child
+ )
+ }
+
+ if index < children.count - 1 {
+ Divider()
+ }
+ }
+
+ if browserStore.isLoadingChildren(of: hierarchyItem) {
+ ProgressView()
+ .frame(maxWidth: .infinity, minHeight: 60)
+ .accessibilityLabel("Loading more \(childrenSectionTitle)")
+ }
+ }
+ }
+ }
+ }
+ .frame(maxWidth: 980, alignment: .leading)
+ }
+
+ private func compactUnavailableContent(
+ title: String,
+ message: String?,
+ symbol: String,
+ retry: (() -> Void)?
+ ) -> some View {
+ ContentUnavailableView {
+ Label(title, systemImage: symbol)
+ } description: {
+ if let message {
+ Text(message)
+ }
+ } actions: {
+ if let retry {
+ Button("Try Again", action: retry)
+ }
+ }
+ .frame(maxWidth: .infinity, minHeight: 120, maxHeight: 160)
+ }
+
+ private func loadDiscoveryContent(
+ for item: PlexMediaItem,
+ forceRefresh: Bool = false
+ ) async {
+ guard item.supportsLibraryMetadataDetails else {
+ return
+ }
+ await browserStore.loadDetailDiscoveryContent(
+ for: item,
+ forceRefresh: forceRefresh
+ )
+ }
+}
+
+struct PlexMediaOverview: View {
+ let item: PlexMediaItem
+ let settingsStore: PlexSettingsStore
+ let serverURL: URL?
+ let showsPlaybackControl: Bool
+ let isPlaybackEnabled: Bool
+ let isPreparingPlayback: Bool
+ let preparePlayback: () -> Void
+ let showsAutomaticDownloadControl: Bool
+ let prepareAutomaticDownload: () -> Void
+ @ScaledMetric(relativeTo: .body) private var artworkWidth: CGFloat = 120
+ @ScaledMetric(relativeTo: .body) private var posterArtworkHeight: CGFloat = 180
+
+ @ViewBuilder
+ var body: some View {
+ if item.usesCinematicHierarchyHero {
+ cinematicOverview
+ } else {
+ compactOverview
+ .scenePadding([.horizontal, .top])
+ }
+ }
+
+ private var cinematicOverview: some View {
+ PlexCinematicHero(
+ primaryImageURL: heroArtworkURL,
+ fallbackImageURL: artworkURL,
+ token: settingsStore.trimmedServerToken,
+ clientContext: PlexClientContext(clientIdentifier: settingsStore.clientIdentifier),
+ placeholderSymbol: item.placeholderSymbol
+ ) {
+ HStack(alignment: .bottom, spacing: 44) {
+ VStack(alignment: .leading, spacing: 18) {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(item.title)
+ .font(.system(size: 42, weight: .bold))
+ .lineLimit(2)
+ .minimumScaleFactor(0.78)
+ .accessibilityAddTraits(.isHeader)
+
+ if !item.hierarchyDestinations.isEmpty {
+ PlexMediaHierarchyBreadcrumbs(destinations: item.hierarchyDestinations)
+ }
+
+ PlexMediaFactsView(
+ presentation: item.factsPresentation,
+ genres: PlexMediaSummaryPresentation(item: item).genres
+ )
+ .font(.headline.weight(.medium))
+ .foregroundStyle(.white.opacity(0.78))
+ }
+
+ GlassEffectContainer(spacing: 10) {
+ HStack(spacing: 10) {
+ if showsPlaybackControl {
+ Button(action: preparePlayback) {
+ if isPreparingPlayback {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Label("Play", systemImage: "play.fill")
+ }
+ }
+ .frame(width: 216)
+ .plexCinematicPrimaryButton()
+ .disabled(!isPlaybackEnabled || isPreparingPlayback)
+ .accessibilityLabel(
+ isPreparingPlayback ? "Preparing \(item.title)" : "Play \(item.title)"
+ )
+ }
+
+ if showsAutomaticDownloadControl {
+ Button(
+ "Download",
+ systemImage: "arrow.down.circle",
+ action: prepareAutomaticDownload
+ )
+ .labelStyle(.iconOnly)
+ .plexCinematicUtilityButton()
+ }
+ }
+ }
+ }
+ .frame(maxWidth: 460, alignment: .leading)
+
+ if let summary = item.summary?.nilIfBlank {
+ PlexMediaDescriptionView(title: item.title, summary: summary)
+ .frame(maxWidth: 560, alignment: .leading)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .foregroundStyle(.white)
+ }
+
+ private var compactOverview: some View {
+ Grid(alignment: .topLeading, horizontalSpacing: 20, verticalSpacing: 0) {
+ GridRow(alignment: .top) {
+ PlexArtworkView(
+ primaryImageURL: artworkURL,
+ fallbackImageURL: nil,
+ token: settingsStore.trimmedServerToken,
+ clientContext: PlexClientContext(clientIdentifier: settingsStore.clientIdentifier),
+ placeholderSymbol: item.placeholderSymbol,
+ width: artworkWidth,
+ height: item.usesSquareArtwork ? artworkWidth : posterArtworkHeight,
+ cornerRadius: 10
+ )
+
+ VStack(alignment: .leading, spacing: 8) {
+ Text(item.title)
+ .font(.title)
+ .fontWeight(.semibold)
+ .accessibilityAddTraits(.isHeader)
+
+ if !item.hierarchyDestinations.isEmpty {
+ PlexMediaHierarchyBreadcrumbs(destinations: item.hierarchyDestinations)
+ }
+
+ PlexMediaFactsView(presentation: item.factsPresentation)
+ .foregroundStyle(.secondary)
+
+ HStack(spacing: 10) {
+ if showsPlaybackControl {
+ Button(action: preparePlayback) {
+ if isPreparingPlayback {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Label("Play", systemImage: "play.fill")
+ }
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(!isPlaybackEnabled || isPreparingPlayback)
+ .accessibilityLabel(
+ isPreparingPlayback ? "Preparing \(item.title)" : "Play \(item.title)"
+ )
+ }
+
+ if showsAutomaticDownloadControl {
+ Button(
+ "Download",
+ systemImage: "arrow.down.circle",
+ action: prepareAutomaticDownload
+ )
+ .buttonStyle(.bordered)
+ }
+ }
+ .controlSize(.large)
+
+ if let summary = item.summary?.nilIfBlank {
+ PlexMediaDescriptionView(title: item.title, summary: summary)
+ }
+ }
+ .frame(maxWidth: 680, alignment: .leading)
+ }
+ }
+ .padding(.vertical, 8)
+ .fixedSize(horizontal: false, vertical: true)
+ .accessibilityElement(children: .contain)
+ }
+
+ private var heroArtworkURL: URL? {
+ guard let serverURL else {
+ return nil
+ }
+ return PlexURLBuilder.mediaURL(serverURL: serverURL, path: item.art?.nilIfBlank)
+ }
+
+ private var artworkURL: URL? {
+ guard let serverURL else {
+ return nil
+ }
+ return PlexURLBuilder.mediaURL(serverURL: serverURL, path: item.preferredArtworkPath)
+ }
+}
+
+struct PlexMediaChildRow: View {
+ let item: PlexMediaItem
+ let settingsStore: PlexSettingsStore
+ let serverURL: URL?
+ @ScaledMetric(relativeTo: .body) private var compactArtworkSize: CGFloat = 54
+ @ScaledMetric(relativeTo: .body) private var posterArtworkWidth: CGFloat = 54
+ @ScaledMetric(relativeTo: .body) private var posterArtworkHeight: CGFloat = 81
+ @ScaledMetric(relativeTo: .body) private var episodeArtworkWidth: CGFloat = 128
+ @ScaledMetric(relativeTo: .body) private var episodeArtworkHeight: CGFloat = 72
+
+ var body: some View {
+ HStack(spacing: 14) {
+ PlexArtworkView(
+ primaryImageURL: artworkURL,
+ fallbackImageURL: fallbackArtworkURL,
+ token: settingsStore.trimmedServerToken,
+ clientContext: PlexClientContext(clientIdentifier: settingsStore.clientIdentifier),
+ placeholderSymbol: spoilerPresentation.isProtected ? "eye.slash" : item.placeholderSymbol,
+ width: artworkWidth,
+ height: artworkHeight,
+ cornerRadius: 6
+ )
+ .plexWatchedIndicator(isWatched: item.isWatched, scale: .compact)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(item.title)
+ .font(.headline)
+
+ if let subtitle = rowSubtitle {
+ Text(subtitle)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+
+ if let summary = spoilerPresentation.summary, item.type?.lowercased() == "episode" {
+ Text(summary)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+
+ if let progress = item.progress {
+ ProgressView(value: progress)
+ .frame(maxWidth: 220)
+ .accessibilityHidden(true)
+ }
+
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .padding(.vertical, 4)
+ .accessibilityElement(children: .combine)
+ .accessibilityValue(item.watchStateAccessibilityValue ?? "")
+ }
+
+ private var artworkURL: URL? {
+ guard let serverURL else {
+ return nil
+ }
+ return PlexURLBuilder.mediaURL(serverURL: serverURL, path: spoilerPresentation.thumbnailPath)
+ }
+
+ private var fallbackArtworkURL: URL? {
+ guard !spoilerPresentation.isProtected else {
+ return nil
+ }
+ guard let serverURL else {
+ return nil
+ }
+ return PlexURLBuilder.mediaURL(
+ serverURL: serverURL,
+ path: item.parentThumb ?? item.grandparentThumb
+ )
+ }
+
+ private var spoilerPresentation: PlexEpisodeSpoilerPresentation {
+ PlexEpisodeSpoilerPresentation(
+ item: item,
+ policy: settingsStore.episodeSpoilerPolicy
+ )
+ }
+
+ private var artworkWidth: CGFloat {
+ switch item.type?.lowercased() {
+ case "episode", "clip": episodeArtworkWidth
+ case "season": posterArtworkWidth
+ default: compactArtworkSize
+ }
+ }
+
+ private var artworkHeight: CGFloat {
+ switch item.type?.lowercased() {
+ case "episode", "clip": episodeArtworkHeight
+ case "season": posterArtworkHeight
+ default: compactArtworkSize
+ }
+ }
+
+ private var rowSubtitle: String? {
+ switch item.type?.lowercased() {
+ case "season":
+ item.leafCount.map { "\($0.formatted()) \($0 == 1 ? "episode" : "episodes")" }
+ case "episode":
+ [item.episodeIdentifier, item.formattedDuration].compactMap { $0 }.joined(separator: " · ").nilIfBlank
+ case "album":
+ item.year.map(String.init)
+ case "track":
+ [item.index.map { "Track \($0)" }, item.formattedDuration]
+ .compactMap { $0 }
+ .joined(separator: " · ")
+ .nilIfBlank
+ case "collection", "playlist", "playlistfolder":
+ [item.itemCountLabel, item.formattedDuration]
+ .compactMap { $0 }
+ .joined(separator: " · ")
+ .nilIfBlank
+ default:
+ item.subtitle
+ }
+ }
+}
+
+private extension PlexMediaItem {
+ var supportsLibraryMetadataDetails: Bool {
+ guard let type = type?.lowercased() else {
+ return true
+ }
+ return type != "collection" && type != "playlist" && type != "playlistfolder"
+ }
+}
diff --git a/PlexBar/Views/PlexMediaDetailsView.swift b/PlexBar/Views/PlexMediaDetailsView.swift
new file mode 100644
index 0000000..30f9699
--- /dev/null
+++ b/PlexBar/Views/PlexMediaDetailsView.swift
@@ -0,0 +1,776 @@
+import PlexModels
+import SwiftUI
+
+struct PlexMediaDetailsView: View {
+ @Environment(PlexDownloadsStore.self) private var downloadsStore
+ @Bindable var browserStore: PlexBrowserStore
+ @Bindable var historyStore: PlexHistoryStore
+ @Bindable var settingsStore: PlexSettingsStore
+ @Bindable var connectionStore: PlexConnectionStore
+ @Bindable var playerCoordinator: PlexPlayerCoordinator
+ let refreshesInitialMetadata: Bool
+ @State private var item: PlexMediaItem
+ @State private var selectedMediaIndex: Int
+ @State private var isPreparingPlayback = false
+ @State private var isPreparingPrimaryExtra = false
+ @State private var isReloadingDetails = false
+ @State private var episodeSeriesCast: [PlexTag] = []
+ @State private var playbackErrorMessage: String?
+ @State private var downloadErrorMessage: String?
+
+ init(
+ initialItem: PlexMediaItem,
+ browserStore: PlexBrowserStore,
+ historyStore: PlexHistoryStore,
+ settingsStore: PlexSettingsStore,
+ connectionStore: PlexConnectionStore,
+ playerCoordinator: PlexPlayerCoordinator,
+ refreshesInitialMetadata: Bool
+ ) {
+ self.browserStore = browserStore
+ self.historyStore = historyStore
+ self.settingsStore = settingsStore
+ self.connectionStore = connectionStore
+ self.playerCoordinator = playerCoordinator
+ self.refreshesInitialMetadata = refreshesInitialMetadata
+ _item = State(initialValue: initialItem)
+ _selectedMediaIndex = State(initialValue: initialItem.defaultPlaybackSource?.mediaIndex ?? 0)
+ }
+
+ var body: some View {
+ ZStack {
+ detailBackdrop
+ .ignoresSafeArea()
+
+ ScrollView {
+ VStack(alignment: .leading, spacing: 0) {
+ mediaHeader
+
+ VStack(alignment: .leading, spacing: 30) {
+ PlexCastAndCrewView(
+ item: item,
+ episodeSeriesCast: episodeSeriesCast,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore
+ )
+
+ PlexMediaMetadataView(item: item)
+ .frame(maxWidth: 980, alignment: .leading)
+
+ if hasVisibleMediaHistory {
+ PlexMediaHistoryView(
+ item: item,
+ historyStore: historyStore,
+ settingsStore: settingsStore,
+ serverURL: connectionStore.resolvedServerURL
+ )
+ }
+
+ if hasVisibleExtras {
+ PlexMediaExtrasView(
+ item: item,
+ browserStore: browserStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator
+ )
+ }
+
+ if hasVisibleRelatedContent {
+ PlexRelatedContentView(
+ item: item,
+ browserStore: browserStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator
+ )
+ }
+ }
+ .frame(maxWidth: 1_180, alignment: .leading)
+ .scenePadding()
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+ .navigationTitle(item.title)
+ .focusedSceneValue(
+ \.plexRefreshCommand,
+ PlexFocusedCommandAction(
+ title: "Reload \(item.title)",
+ isEnabled: !isReloadingContent,
+ perform: reloadDetails
+ )
+ )
+ .task(id: item.ratingKey) {
+ episodeSeriesCast = []
+ let requestedItem = item
+ async let capabilityLoad: Void = browserStore.loadLibraryProviderCapabilities()
+ async let discoveryLoad: Void = browserStore.loadDetailDiscoveryContent(for: requestedItem)
+ async let historyLoad: Void = historyStore.loadMediaHistory(for: requestedItem)
+ await loadInitialDetailsIfNeeded()
+ await loadEpisodeSeriesCast()
+ _ = await (capabilityLoad, discoveryLoad, historyLoad)
+ }
+ .toolbar {
+ ToolbarItem {
+ PlexMetadataRefreshButton(item: item, browserStore: browserStore)
+ }
+ }
+ .alert(
+ "Playback Error",
+ isPresented: Binding(
+ get: { playbackErrorMessage != nil },
+ set: { isPresented in
+ if !isPresented {
+ playbackErrorMessage = nil
+ }
+ }
+ )
+ ) {
+ Button("OK") {}
+ } message: {
+ Text(playbackErrorMessage ?? "Unknown playback error.")
+ }
+ .alert(
+ "Download Error",
+ isPresented: Binding(
+ get: { downloadErrorMessage != nil },
+ set: { if !$0 { downloadErrorMessage = nil } }
+ )
+ ) {
+ Button("OK") {}
+ } message: {
+ Text(downloadErrorMessage ?? "Unknown download error.")
+ }
+ }
+
+ @ViewBuilder
+ private var detailBackdrop: some View {
+ if item.usesCinematicDetailHero {
+ Color(nsColor: .windowBackgroundColor)
+ } else {
+ PlexArtworkBackdrop(
+ primaryImageURL: backdropPosterURL,
+ fallbackImageURL: backdropFallbackPosterURL,
+ token: settingsStore.trimmedServerToken,
+ clientContext: clientContext
+ )
+ }
+ }
+
+ private var hasVisibleMediaHistory: Bool {
+ historyStore.mediaHistoryPresentation(for: item)?.isVisible == true
+ }
+
+ private var hasVisibleExtras: Bool {
+ browserStore.isLoadingMediaExtras(for: item)
+ || browserStore.mediaExtrasErrorMessage(for: item) != nil
+ || !browserStore.mediaExtras(for: item).isEmpty
+ }
+
+ private var hasVisibleRelatedContent: Bool {
+ browserStore.isLoadingRelatedContent(for: item)
+ || browserStore.relatedContentErrorMessage(for: item) != nil
+ || !browserStore.relatedHubs(for: item).isEmpty
+ }
+
+ @ViewBuilder
+ private var mediaHeader: some View {
+ if let photoPresentation = PlexPhotoPresentation(item: item) {
+ photoHeader(photoPresentation)
+ } else {
+ standardMediaHeader
+ }
+ }
+
+ private func photoHeader(_ presentation: PlexPhotoPresentation) -> some View {
+ VStack(alignment: .leading, spacing: 24) {
+ PlexPhotoDetailStage(
+ presentation: presentation,
+ title: item.title,
+ serverURL: connectionStore.resolvedServerURL,
+ token: settingsStore.trimmedServerToken,
+ clientContext: clientContext
+ )
+
+ VStack(alignment: .leading, spacing: 18) {
+ titleBlock
+
+ if let summary = spoilerPresentation.summary {
+ Text(summary)
+ .font(.body)
+ .textSelection(.enabled)
+ }
+
+ PlexMediaMetadataView(item: item)
+ }
+ .frame(maxWidth: 760, alignment: .leading)
+ }
+ .frame(maxWidth: 1_100, alignment: .leading)
+ }
+
+ @ViewBuilder
+ private var standardMediaHeader: some View {
+ if item.usesCinematicDetailHero {
+ cinematicMediaHeader
+ } else {
+ compactMediaHeader
+ }
+ }
+
+ private var cinematicMediaHeader: some View {
+ PlexCinematicMediaHero(
+ primaryImageURL: heroArtworkURL,
+ fallbackImageURL: posterURL ?? detailFallbackArtworkURL,
+ clearLogoURL: clearLogoURL,
+ title: item.title,
+ logoAccessibilityLabel: item.images.first {
+ $0.type.caseInsensitiveCompare("clearLogo") == .orderedSame
+ }?.alt,
+ token: settingsStore.trimmedServerToken,
+ clientContext: clientContext,
+ placeholderSymbol: spoilerPresentation.isProtected ? "eye.slash" : item.placeholderSymbol,
+ playbackTitle: item.title,
+ ratingsItem: item,
+ hasResumePosition: item.hasResumePosition,
+ resumeProgress: item.progress,
+ isPreparingPlayback: isPreparingPlayback,
+ isPlaybackEnabled: selectedPlaybackSource != nil && !isPreparingPrimaryExtra,
+ preparePlayback: preparePlayback
+ ) {
+ heroActions
+ } details: {
+ heroDescription
+ }
+ }
+
+ private var compactMediaHeader: some View {
+ Grid(alignment: .topLeading, horizontalSpacing: 28, verticalSpacing: 0) {
+ GridRow(alignment: .top) {
+ PlexArtworkView(
+ primaryImageURL: posterURL,
+ fallbackImageURL: detailFallbackArtworkURL,
+ token: settingsStore.trimmedServerToken,
+ clientContext: clientContext,
+ placeholderSymbol: item.placeholderSymbol,
+ width: 220,
+ height: item.usesSquareArtwork ? 220 : 330,
+ cornerRadius: 16
+ )
+
+ VStack(alignment: .leading, spacing: 18) {
+ titleBlock
+
+ HStack(spacing: 10) {
+ PlexPlaybackStartControl(
+ title: item.title,
+ hasResumePosition: item.hasResumePosition,
+ resumeProgress: item.progress,
+ isPreparing: isPreparingPlayback,
+ isEnabled: selectedPlaybackSource != nil && !isPreparingPrimaryExtra,
+ action: preparePlayback
+ )
+ .fixedSize()
+
+ if showsDownloadControl {
+ downloadButton
+ .buttonStyle(.glass)
+ }
+
+ PlexPlaybackQueueMenu(
+ item: item,
+ playerCoordinator: playerCoordinator
+ )
+ .buttonStyle(.glass)
+ }
+ .controlSize(.large)
+
+ if playbackVersionOptions.count > 1 {
+ Picker("Version", selection: $selectedMediaIndex) {
+ ForEach(playbackVersionOptions) { option in
+ Text(option.label)
+ .tag(option.id)
+ }
+ }
+ .pickerStyle(.menu)
+ .fixedSize()
+ }
+
+ if let summary = spoilerPresentation.summary {
+ PlexMediaDescriptionView(title: item.title, summary: summary)
+ }
+ }
+ .frame(maxWidth: 680, alignment: .leading)
+ }
+ }
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ private var heroActions: some View {
+ VStack(alignment: .leading, spacing: 18) {
+ GlassEffectContainer(spacing: 10) {
+ HStack(spacing: 10) {
+ if let primaryExtraTitle = item.primaryExtraActionTitle {
+ PlexPrimaryExtraButton(
+ title: primaryExtraTitle,
+ isPreparing: isPreparingPrimaryExtra,
+ isEnabled: !isPreparingPlayback,
+ action: preparePrimaryExtra
+ )
+ .plexCinematicUtilityButton()
+ .help("Play \(primaryExtraTitle)")
+ }
+
+ PlexWatchedStateButton(item: $item, browserStore: browserStore)
+ .plexCinematicUtilityButton()
+
+ if showsDownloadControl {
+ downloadButton
+ .plexCinematicUtilityButton()
+ }
+
+ PlexPersonalRatingMenu(item: $item, browserStore: browserStore)
+ .plexCinematicUtilityButton()
+
+ PlexPlaybackQueueMenu(
+ item: item,
+ playerCoordinator: playerCoordinator
+ )
+ .plexCinematicUtilityButton()
+ }
+ .labelStyle(.iconOnly)
+ }
+
+ if playbackVersionOptions.count > 1 {
+ Picker("Version", selection: $selectedMediaIndex) {
+ ForEach(playbackVersionOptions) { option in
+ Text(option.label)
+ .tag(option.id)
+ }
+ }
+ .pickerStyle(.menu)
+ .fixedSize()
+ }
+ }
+ }
+
+ private var heroDescription: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ PlexMediaFactsView(
+ presentation: item.factsPresentation,
+ genres: PlexMediaSummaryPresentation(item: item).genres
+ )
+ .font(.headline.weight(.medium))
+ .foregroundStyle(.white.opacity(0.72))
+
+ if let summary = spoilerPresentation.summary {
+ PlexMediaDescriptionView(title: item.title, summary: summary)
+ }
+
+ }
+ }
+
+ private var titleBlock: some View {
+ VStack(alignment: .leading, spacing: 5) {
+ Text(item.title)
+ .font(.largeTitle)
+ .fontWeight(.semibold)
+ .accessibilityAddTraits(.isHeader)
+
+ if !item.hierarchyDestinations.isEmpty {
+ PlexMediaHierarchyBreadcrumbs(destinations: item.hierarchyDestinations)
+ }
+
+ PlexMediaFactsView(presentation: item.factsPresentation)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ @ViewBuilder
+ private var downloadButton: some View {
+ if downloadsStore.containsDownload(for: item) {
+ Button("Downloaded", systemImage: "checkmark.circle.fill") {}
+ .disabled(true)
+ } else if downloadsStore.isDownloading(item) {
+ Button("Downloading", systemImage: "arrow.down.circle") {}
+ .disabled(true)
+ } else {
+ Button("Download", systemImage: "arrow.down.circle", action: prepareDownload)
+ .disabled(selectedPlaybackSource == nil)
+ }
+ }
+
+ private var showsDownloadControl: Bool {
+ downloadsStore.containsDownload(for: item)
+ || downloadsStore.isDownloading(item)
+ || downloadsStore.canCreateDownload(for: item)
+ }
+
+ private func prepareDownload() {
+ guard let selectedPlaybackSource else { return }
+ Task {
+ do {
+ try await downloadsStore.download(item, source: selectedPlaybackSource)
+ } catch {
+ downloadErrorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ private var posterURL: URL? {
+ guard let serverURL = connectionStore.resolvedServerURL else {
+ return nil
+ }
+ return PlexURLBuilder.mediaURL(serverURL: serverURL, path: spoilerPresentation.thumbnailPath)
+ }
+
+ private var heroArtworkURL: URL? {
+ guard let serverURL = connectionStore.resolvedServerURL else {
+ return nil
+ }
+ return PlexURLBuilder.mediaURL(serverURL: serverURL, path: item.art?.nilIfBlank)
+ }
+
+ private var clearLogoURL: URL? {
+ guard let serverURL = connectionStore.resolvedServerURL else {
+ return nil
+ }
+ return PlexURLBuilder.mediaURL(serverURL: serverURL, path: item.clearLogoPath)
+ }
+
+ private func reloadDetails() {
+ guard !isReloadingContent else {
+ return
+ }
+ Task {
+ let requestedItem = item
+ async let discoveryLoad: Void = browserStore.loadDetailDiscoveryContent(
+ for: requestedItem,
+ forceRefresh: true
+ )
+ async let historyLoad: Void = historyStore.loadMediaHistory(
+ for: requestedItem,
+ forceRefresh: true
+ )
+ await loadDetails()
+ await loadEpisodeSeriesCast()
+ _ = await (discoveryLoad, historyLoad)
+ }
+ }
+
+ private var isReloadingContent: Bool {
+ isReloadingDetails
+ || browserStore.detailDiscoveryPresentation(for: item).isLoading
+ || historyStore.mediaHistoryPresentation(for: item)?.isLoading == true
+ }
+
+ private func loadDetails() async {
+ guard !isReloadingDetails else {
+ return
+ }
+ isReloadingDetails = true
+ defer { isReloadingDetails = false }
+
+ let details = await browserStore.details(for: item)
+ if details != item {
+ item = details
+ selectedMediaIndex = details.defaultPlaybackSource?.mediaIndex ?? 0
+ }
+ }
+
+ private func loadInitialDetailsIfNeeded() async {
+ guard refreshesInitialMetadata else {
+ return
+ }
+ await loadDetails()
+ }
+
+ private func loadEpisodeSeriesCast() async {
+ let requestedRatingKey = item.ratingKey
+ let cast = await browserStore.episodeSeriesCast(for: item)
+ guard item.ratingKey == requestedRatingKey, !Task.isCancelled else {
+ return
+ }
+ episodeSeriesCast = cast
+ }
+
+ private var backdropPosterURL: URL? {
+ guard let serverURL = connectionStore.resolvedServerURL else {
+ return nil
+ }
+ return PlexURLBuilder.mediaURL(
+ serverURL: serverURL,
+ path: item.art?.nilIfBlank ?? item.posterArtworkPath
+ )
+ }
+
+ private var clientContext: PlexClientContext {
+ PlexClientContext(clientIdentifier: settingsStore.clientIdentifier)
+ }
+
+ private var backdropFallbackPosterURL: URL? {
+ guard let serverURL = connectionStore.resolvedServerURL else {
+ return nil
+ }
+ return PlexURLBuilder.mediaURL(
+ serverURL: serverURL,
+ path: item.parentThumb ?? item.grandparentThumb
+ )
+ }
+
+ private var detailFallbackArtworkURL: URL? {
+ guard !spoilerPresentation.isProtected else {
+ return nil
+ }
+ return backdropFallbackPosterURL
+ }
+
+ private var spoilerPresentation: PlexEpisodeSpoilerPresentation {
+ PlexEpisodeSpoilerPresentation(
+ item: item,
+ policy: settingsStore.episodeSpoilerPolicy
+ )
+ }
+
+ private var playbackVersionOptions: [PlexPlaybackVersionOption] {
+ item.playbackVersionOptions
+ }
+
+ private var selectedPlaybackSource: PlexPlaybackSource? {
+ item.playbackSource(mediaIndex: selectedMediaIndex)
+ }
+
+ private var activeConnectionKind: PlexConnectionKind? {
+ connectionStore.activeConnection?.kind ?? settingsStore.cachedConnectionKind
+ }
+
+ private func preparePlayback(_ startOption: PlexPlaybackStartOption) {
+ guard !isPreparingPlayback, !isPreparingPrimaryExtra else {
+ return
+ }
+
+ isPreparingPlayback = true
+ Task {
+ defer { isPreparingPlayback = false }
+
+ do {
+ let playbackItem = try await browserStore.refreshedPlayableDetails(for: item)
+ let videoQuality = settingsStore.videoQuality(for: activeConnectionKind)
+ let presentation = try await playbackPresentation(
+ for: playbackItem,
+ startOption: startOption,
+ videoQuality: videoQuality
+ )
+ playerCoordinator.present(presentation)
+ } catch {
+ playbackErrorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ private func preparePrimaryExtra() {
+ guard !isPreparingPlayback, !isPreparingPrimaryExtra else {
+ return
+ }
+
+ isPreparingPrimaryExtra = true
+ Task {
+ defer { isPreparingPrimaryExtra = false }
+
+ do {
+ let extra = try await browserStore.primaryExtra(for: item)
+ guard let source = extra.defaultPlaybackSource else {
+ throw PlexAPIError.noPlayableMedia
+ }
+
+ let videoQuality = settingsStore.videoQuality(for: activeConnectionKind)
+ let plan = try await browserStore.playbackPlan(
+ for: extra,
+ source: source,
+ videoQuality: videoQuality,
+ startTimeOverride: 0
+ )
+ playerCoordinator.present(PlexPlaybackPresentation(
+ item: extra,
+ plan: plan,
+ queue: nil,
+ videoQuality: videoQuality,
+ serverIdentifier: connectionStore.activeConnection?.serverID
+ ?? settingsStore.selectedServerIdentifier?.nilIfBlank
+ ))
+ } catch {
+ playbackErrorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ private func playbackQueue(for item: PlexMediaItem) async throws -> PlexPlaybackQueue? {
+ guard item.continuousPlayQueueType != nil else {
+ return nil
+ }
+ return try await browserStore.continuousPlayQueue(for: item)
+ }
+
+ private func playbackPresentation(
+ for playbackItem: PlexMediaItem,
+ startOption: PlexPlaybackStartOption,
+ videoQuality: PlexVideoQuality
+ ) async throws -> PlexPlaybackPresentation {
+ guard let selectedPlaybackSource = playbackItem.playbackSource(
+ mediaIndex: selectedMediaIndex
+ ) else {
+ throw PlexAPIError.noPlayableMedia
+ }
+
+ let serverIdentifier = connectionStore.activeConnection?.serverID
+ ?? settingsStore.selectedServerIdentifier?.nilIfBlank
+ if let extrasPrefixCount = PlexCinemaPreplayRequestPolicy.extrasPrefixCount(
+ for: playbackItem,
+ startOption: startOption,
+ preference: settingsStore.cinemaPreplayPreference
+ ) {
+ let queue = try await browserStore.cinemaPlayQueue(
+ for: playbackItem,
+ extrasPrefixCount: extrasPrefixCount
+ )
+ let firstItem = try await browserStore.refreshedPlayableDetails(
+ for: queue.currentItem
+ )
+ let sourcePreference = PlexPlaybackQueueSourcePreference(
+ ratingKey: playbackItem.ratingKey,
+ source: selectedPlaybackSource
+ )
+ guard let firstSource = sourcePreference.source(for: firstItem)
+ ?? firstItem.defaultPlaybackSource else {
+ throw PlexAPIError.noPlayableMedia
+ }
+ let plan = try await browserStore.playbackPlan(
+ for: firstItem,
+ source: firstSource,
+ videoQuality: videoQuality,
+ startTimeOverride: 0
+ )
+ return PlexPlaybackPresentation(
+ item: firstItem,
+ plan: plan,
+ queue: queue,
+ videoQuality: videoQuality,
+ serverIdentifier: serverIdentifier,
+ queueSourcePreference: sourcePreference
+ )
+ }
+
+ async let plan = browserStore.playbackPlan(
+ for: playbackItem,
+ source: selectedPlaybackSource,
+ videoQuality: videoQuality,
+ startTimeOverride: startOption.startTimeOverride
+ )
+ async let queue = playbackQueue(for: playbackItem)
+ return try await PlexPlaybackPresentation(
+ item: playbackItem,
+ plan: plan,
+ queue: queue,
+ videoQuality: videoQuality,
+ serverIdentifier: serverIdentifier
+ )
+ }
+}
+
+private struct PlexPrimaryExtraButton: View {
+ let title: String
+ let isPreparing: Bool
+ let isEnabled: Bool
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ Label(isPreparing ? "Preparing \(title)" : title, systemImage: "play.rectangle")
+ }
+ .disabled(!isEnabled || isPreparing)
+ .accessibilityLabel(isPreparing ? "Preparing \(title)" : "Play \(title)")
+ }
+}
+
+struct PlexPlaybackStartControl: View {
+ let title: String
+ let hasResumePosition: Bool
+ let resumeProgress: Double?
+ let isPreparing: Bool
+ let isEnabled: Bool
+ var width: CGFloat = 216
+ let action: (PlexPlaybackStartOption) -> Void
+
+ var body: some View {
+ if hasResumePosition {
+ Menu {
+ Button("Play from Beginning", systemImage: "backward.end.fill") {
+ action(.beginning)
+ }
+ } label: {
+ label("Resume")
+ } primaryAction: {
+ action(.resume)
+ }
+ .menuStyle(.button)
+ .plexCinematicPrimaryButton()
+ .disabled(isPreparing || !isEnabled)
+ .accessibilityLabel(isPreparing ? "Preparing \(title)" : "Resume \(title)")
+ .accessibilityValue(resumeAccessibilityValue)
+ .accessibilityHint("Open the menu to play from the beginning.")
+ } else {
+ Button {
+ action(.beginning)
+ } label: {
+ label("Play")
+ }
+ .plexCinematicPrimaryButton()
+ .accessibilityLabel(isPreparing ? "Preparing \(title)" : "Play \(title)")
+ .disabled(isPreparing || !isEnabled)
+ }
+ }
+
+ @ViewBuilder
+ private func label(_ text: String) -> some View {
+ ZStack {
+ GeometryReader { geometry in
+ Rectangle()
+ .fill(.white.opacity(0.88))
+ .frame(width: geometry.size.width * visibleResumeProgress)
+ .frame(maxHeight: .infinity)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .accessibilityHidden(true)
+ .allowsHitTesting(false)
+
+ if isPreparing {
+ ProgressView()
+ .controlSize(.small)
+ .tint(.black.opacity(0.82))
+ } else {
+ Label(text, systemImage: "play.fill")
+ .fontWeight(.semibold)
+ .foregroundStyle(.black.opacity(0.90))
+ }
+ }
+ .frame(width: width, height: 44)
+ .compositingGroup()
+ .clipShape(.capsule)
+ }
+
+ private var visibleResumeProgress: Double {
+ guard hasResumePosition,
+ let resumeProgress,
+ resumeProgress.isFinite else {
+ return 0
+ }
+ return min(max(resumeProgress, 0), 1)
+ }
+
+ private var resumeAccessibilityValue: String {
+ guard visibleResumeProgress > 0 else {
+ return ""
+ }
+ return "\(visibleResumeProgress.formatted(.percent.precision(.fractionLength(0)))) watched"
+ }
+}
diff --git a/PlexBar/Views/PlexMediaDiscoveryView.swift b/PlexBar/Views/PlexMediaDiscoveryView.swift
new file mode 100644
index 0000000..cb7ea5a
--- /dev/null
+++ b/PlexBar/Views/PlexMediaDiscoveryView.swift
@@ -0,0 +1,30 @@
+import PlexModels
+import SwiftUI
+
+struct PlexMediaDiscoveryView: View {
+ let item: PlexMediaItem
+ @Bindable var browserStore: PlexBrowserStore
+ @Bindable var settingsStore: PlexSettingsStore
+ @Bindable var connectionStore: PlexConnectionStore
+ @Bindable var playerCoordinator: PlexPlayerCoordinator
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 30) {
+ PlexMediaExtrasView(
+ item: item,
+ browserStore: browserStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator
+ )
+
+ PlexRelatedContentView(
+ item: item,
+ browserStore: browserStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator
+ )
+ }
+ }
+}
diff --git a/PlexBar/Views/PlexMediaExtrasView.swift b/PlexBar/Views/PlexMediaExtrasView.swift
new file mode 100644
index 0000000..7f5345b
--- /dev/null
+++ b/PlexBar/Views/PlexMediaExtrasView.swift
@@ -0,0 +1,80 @@
+import PlexModels
+import SwiftUI
+
+struct PlexMediaExtrasView: View {
+ let item: PlexMediaItem
+ @Bindable var browserStore: PlexBrowserStore
+ @Bindable var settingsStore: PlexSettingsStore
+ @Bindable var connectionStore: PlexConnectionStore
+ @Bindable var playerCoordinator: PlexPlayerCoordinator
+
+ var body: some View {
+ Group {
+ if isLoading, extras.isEmpty {
+ ProgressView("Loading Extras")
+ .frame(maxWidth: 980, minHeight: 120)
+ .accessibilityLabel("Loading Extras")
+ } else if let errorMessage, extras.isEmpty {
+ ContentUnavailableView {
+ Label("Couldn’t Load Extras", systemImage: "exclamationmark.triangle")
+ } description: {
+ Text(errorMessage)
+ } actions: {
+ Button("Try Again", action: refresh)
+ }
+ .frame(maxWidth: 980, minHeight: 180)
+ } else if !extras.isEmpty {
+ VStack(alignment: .leading, spacing: 12) {
+ if let errorMessage {
+ HStack(spacing: 10) {
+ Label("Extras Didn’t Refresh", systemImage: "exclamationmark.triangle")
+ Text(errorMessage)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ Spacer()
+ Button("Try Again", action: refresh)
+ }
+ .font(.callout)
+ .frame(maxWidth: 980, alignment: .leading)
+ }
+
+ PlexMediaShelf(
+ title: "Extras",
+ items: extras,
+ artworkLayout: .automatic,
+ showAllRoute: nil,
+ browserStore: browserStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator
+ )
+ }
+ .overlay(alignment: .top) {
+ if isLoading {
+ ProgressView()
+ .progressViewStyle(.linear)
+ .accessibilityLabel("Refreshing Extras")
+ }
+ }
+ }
+ }
+ }
+
+ private var extras: [PlexMediaItem] {
+ browserStore.mediaExtras(for: item)
+ }
+
+ private var isLoading: Bool {
+ browserStore.isLoadingMediaExtras(for: item)
+ }
+
+ private var errorMessage: String? {
+ browserStore.mediaExtrasErrorMessage(for: item)
+ }
+
+ private func refresh() {
+ Task {
+ await browserStore.loadMediaExtras(for: item, forceRefresh: true)
+ }
+ }
+}
diff --git a/PlexBar/Views/PlexMediaFactsView.swift b/PlexBar/Views/PlexMediaFactsView.swift
new file mode 100644
index 0000000..58fe32a
--- /dev/null
+++ b/PlexBar/Views/PlexMediaFactsView.swift
@@ -0,0 +1,115 @@
+import SwiftUI
+
+struct PlexMediaFactsView: View {
+ let presentation: PlexMediaFactsPresentation
+ var genres: String? = nil
+ var badgeFont: Font = .subheadline
+ @ScaledMetric(relativeTo: .headline) private var separatorPadding = 6.0
+
+ private enum Segment {
+ case text(String)
+ case contentRating(String)
+ }
+
+ private var segments: [Segment] {
+ var segments = presentation.facts.map(Segment.text)
+ if let contentRating = presentation.contentRating {
+ segments.append(.contentRating(contentRating))
+ }
+ if let genres {
+ segments.append(.text(genres))
+ }
+ return segments
+ }
+
+ var body: some View {
+ let segments = segments
+
+ if !segments.isEmpty {
+ ViewThatFits(in: .horizontal) {
+ HStack(alignment: .center, spacing: 0) {
+ ForEach(Array(segments.enumerated()), id: \.offset) { index, segment in
+ if index > 0 {
+ separator
+ }
+ segmentView(segment)
+ }
+ }
+ .fixedSize(horizontal: true, vertical: false)
+
+ VStack(alignment: .leading, spacing: 6) {
+ ForEach(Array(segments.enumerated()), id: \.offset) { _, segment in
+ segmentView(segment)
+ }
+ }
+ }
+ .fixedSize(horizontal: false, vertical: true)
+ .accessibilityElement(children: .combine)
+ }
+ }
+
+ @ViewBuilder
+ private func segmentView(_ segment: Segment) -> some View {
+ switch segment {
+ case .text(let text):
+ Text(text)
+ .fixedSize(horizontal: false, vertical: true)
+ case .contentRating(let rating):
+ PlexContentRatingBadge(rating: rating, font: badgeFont)
+ }
+ }
+
+ private var separator: some View {
+ Text("·")
+ .fixedSize()
+ .padding(.horizontal, separatorPadding)
+ .accessibilityHidden(true)
+ }
+}
+
+struct PlexContentRatingBadge: View {
+ let rating: String
+ var font: Font = .subheadline
+ @Environment(\.colorSchemeContrast) private var contrast
+ @ScaledMetric(relativeTo: .body) private var horizontalPadding = 4.0
+ @ScaledMetric(relativeTo: .body) private var verticalPadding = 1.0
+
+ var body: some View {
+ Text(rating)
+ .font(font.weight(.medium))
+ .lineLimit(nil)
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(.horizontal, horizontalPadding)
+ .padding(.vertical, verticalPadding)
+ .overlay {
+ RoundedRectangle(cornerRadius: 2)
+ .strokeBorder(.foreground.opacity(contrast == .increased ? 1 : 0.55), lineWidth: 0.75)
+ .accessibilityHidden(true)
+ }
+ .accessibilityLabel(Text("Content rating: \(rating)"))
+ }
+}
+
+#Preview("Content ratings") {
+ VStack(alignment: .leading, spacing: 16) {
+ ForEach(["TV-Y7-FV", "TV-MA", "PG-13", "NC-17", "12A", "FSK 16", "NR"], id: \.self) { rating in
+ PlexMediaFactsView(presentation: .init(
+ facts: ["36 min", "April 1, 2025"],
+ contentRating: rating
+ ))
+ }
+ }
+ .font(.headline)
+ .padding()
+ .frame(width: 320)
+}
+
+#Preview("Narrow metadata") {
+ PlexMediaFactsView(presentation: .init(
+ facts: ["36 min", "September 12, 2026"],
+ contentRating: "TV-MA (L, S, V)"
+ ))
+ .font(.headline)
+ .padding()
+ .frame(width: 180)
+}
diff --git a/PlexBar/Views/PlexMediaHierarchyNavigation.swift b/PlexBar/Views/PlexMediaHierarchyNavigation.swift
new file mode 100644
index 0000000..3299418
--- /dev/null
+++ b/PlexBar/Views/PlexMediaHierarchyNavigation.swift
@@ -0,0 +1,64 @@
+import SwiftUI
+
+struct PlexMediaHierarchyBreadcrumbs: View {
+ let destinations: [PlexMediaHierarchyDestination]
+
+ var body: some View {
+ HStack(spacing: 7) {
+ ForEach(destinations) { destination in
+ NavigationLink(
+ value: PlexNavigationRoute.media(destination.route)
+ ) {
+ Text(destination.title)
+ .lineLimit(1)
+ }
+ .buttonStyle(.link)
+ .accessibilityLabel("Open \(destination.relationship.rawValue) \(destination.title)")
+
+ if destination.id != destinations.last?.id {
+ Image(systemName: "chevron.right")
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ .accessibilityHidden(true)
+ }
+ }
+ }
+ .accessibilityElement(children: .contain)
+ }
+}
+
+struct PlexMediaHierarchyNavigationMenu: View {
+ let destinations: [PlexMediaHierarchyDestination]
+
+ var body: some View {
+ if !destinations.isEmpty {
+ Menu("Go to", systemImage: "arrow.up.right") {
+ ForEach(destinations) { destination in
+ NavigationLink(
+ value: PlexNavigationRoute.media(destination.route)
+ ) {
+ Label(
+ "\(destination.relationship.rawValue): \(destination.title)",
+ systemImage: destination.relationship.systemImage
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+private extension PlexMediaHierarchyDestination.Relationship {
+ var systemImage: String {
+ switch self {
+ case .show:
+ "tv"
+ case .season:
+ "rectangle.stack"
+ case .artist:
+ "music.mic"
+ case .album:
+ "square.stack"
+ }
+ }
+}
diff --git a/PlexBar/Views/PlexMediaHistoryView.swift b/PlexBar/Views/PlexMediaHistoryView.swift
new file mode 100644
index 0000000..ae70535
--- /dev/null
+++ b/PlexBar/Views/PlexMediaHistoryView.swift
@@ -0,0 +1,289 @@
+import PlexModels
+import SwiftUI
+
+struct PlexMediaHistoryView: View {
+ let item: PlexMediaItem
+ let historyStore: PlexHistoryStore
+ let settingsStore: PlexSettingsStore
+ let serverURL: URL?
+
+ var body: some View {
+ if let presentation = historyStore.mediaHistoryPresentation(for: item),
+ presentation.isVisible
+ {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack(alignment: .firstTextBaseline, spacing: 12) {
+ Text("Watch History")
+ .font(.title2.weight(.semibold))
+ .accessibilityAddTraits(.isHeader)
+
+ Spacer(minLength: 12)
+
+ if !presentation.items.isEmpty {
+ Text(summary(for: presentation.items.count))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ PlexMediaHistoryContent(
+ sourceItem: item,
+ presentation: presentation,
+ historyStore: historyStore,
+ settingsStore: settingsStore,
+ serverURL: serverURL
+ )
+ }
+ .frame(maxWidth: 980, alignment: .leading)
+ }
+ }
+
+ private func summary(for playCount: Int) -> String {
+ let plays = playCount == 1 ? "1 play" : "\(playCount) plays"
+ return "\(plays) · \(historyStore.historyWindowLabel)"
+ }
+}
+
+struct PlexMediaHistoryListSection: View {
+ let item: PlexMediaItem
+ let historyStore: PlexHistoryStore
+ let settingsStore: PlexSettingsStore
+ let serverURL: URL?
+
+ var body: some View {
+ if let presentation = historyStore.mediaHistoryPresentation(for: item),
+ presentation.isVisible
+ {
+ Section {
+ PlexMediaHistoryContent(
+ sourceItem: item,
+ presentation: presentation,
+ historyStore: historyStore,
+ settingsStore: settingsStore,
+ serverURL: serverURL
+ )
+ } header: {
+ Text("Watch History")
+ } footer: {
+ if !presentation.items.isEmpty {
+ Text(historyStore.historyWindowLabel)
+ }
+ }
+ }
+ }
+}
+
+private struct PlexMediaHistoryContent: View {
+ private static let visibleItemLimit = 5
+
+ let sourceItem: PlexMediaItem
+ let presentation: PlexMediaHistoryPresentation
+ let historyStore: PlexHistoryStore
+ let settingsStore: PlexSettingsStore
+ let serverURL: URL?
+
+ private var visibleItems: [PlexHistoryItem] {
+ Array(presentation.items.prefix(Self.visibleItemLimit))
+ }
+
+ var body: some View {
+ if presentation.items.isEmpty {
+ if presentation.isLoading {
+ ProgressView("Loading Watch History…")
+ .accessibilityLabel("Loading watch history for \(sourceItem.title)")
+ } else if presentation.errorMessage != nil {
+ historyError
+ }
+ } else {
+ VStack(alignment: .leading, spacing: 0) {
+ ForEach(Array(visibleItems.enumerated()), id: \.element.id) { index, historyItem in
+ mediaHistoryRow(for: historyItem)
+
+ if index < visibleItems.count - 1 {
+ Divider()
+ }
+ }
+
+ if presentation.items.count > visibleItems.count {
+ Text("Showing the latest \(visibleItems.count) of \(presentation.items.count) plays")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ .padding(.top, 8)
+ }
+
+ if presentation.isLoading {
+ ProgressView()
+ .controlSize(.small)
+ .padding(.top, 8)
+ .accessibilityLabel("Refreshing watch history for \(sourceItem.title)")
+ } else if presentation.errorMessage != nil {
+ historyError
+ .padding(.top, 8)
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func mediaHistoryRow(for historyItem: PlexHistoryItem) -> some View {
+ let account = historyItem.watcherAccount(using: historyStore.accountsByID)
+ let device = historyItem.playbackDevice(using: historyStore.devicesByID)
+ let route = historyItem.mediaRoute
+ let canNavigate = route?.ratingKey != sourceItem.ratingKey
+
+ if canNavigate, let route {
+ NavigationLink(value: PlexNavigationRoute.media(route)) {
+ PlexMediaHistoryRow(
+ historyItem: historyItem,
+ account: account,
+ device: device,
+ settingsStore: settingsStore,
+ serverURL: serverURL,
+ showsNavigationIndicator: true
+ )
+ }
+ .buttonStyle(.plain)
+ } else {
+ PlexMediaHistoryRow(
+ historyItem: historyItem,
+ account: account,
+ device: device,
+ settingsStore: settingsStore,
+ serverURL: serverURL,
+ showsNavigationIndicator: false
+ )
+ }
+ }
+
+ private var historyError: some View {
+ HStack(spacing: 10) {
+ Label("Couldn’t Load Watch History", systemImage: "exclamationmark.triangle")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ Button("Try Again") {
+ Task {
+ await historyStore.loadMediaHistory(for: sourceItem, forceRefresh: true)
+ }
+ }
+ .controlSize(.small)
+ }
+ }
+}
+
+private struct PlexMediaHistoryRow: View {
+ let historyItem: PlexHistoryItem
+ let account: PlexAccount?
+ let device: PlexHistoryDevice?
+ let settingsStore: PlexSettingsStore
+ let serverURL: URL?
+ let showsNavigationIndicator: Bool
+
+ private var presentation: PlexMediaHistoryRowPresentation {
+ PlexMediaHistoryRowPresentation(
+ item: historyItem,
+ account: account,
+ device: device
+ )
+ }
+
+ private var clientContext: PlexClientContext {
+ PlexClientContext(clientIdentifier: settingsStore.clientIdentifier)
+ }
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 14) {
+ PlexArtworkView(
+ primaryImageURL: artworkURL,
+ fallbackImageURL: transcodedArtworkURL,
+ token: settingsStore.trimmedServerToken,
+ clientContext: clientContext,
+ placeholderSymbol: historyItem.contentKind.symbolName,
+ width: 46,
+ height: 64,
+ cornerRadius: 8
+ )
+
+ VStack(alignment: .leading, spacing: 5) {
+ Text(presentation.title)
+ .font(.subheadline.weight(.semibold))
+ .lineLimit(2)
+
+ if let subtitle = presentation.subtitle {
+ Text(subtitle)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+
+ HStack(spacing: 7) {
+ if let account {
+ PlexAvatarView(
+ thumb: account.thumb,
+ serverURL: serverURL,
+ serverToken: settingsStore.trimmedServerToken,
+ userToken: settingsStore.trimmedUserToken,
+ clientContext: clientContext,
+ size: 18
+ )
+ } else {
+ Image(systemName: "person.crop.circle")
+ .foregroundStyle(.secondary)
+ }
+
+ Text(presentation.contextLine)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+
+ Spacer(minLength: 8)
+
+ if let viewedAt = historyItem.viewedAt {
+ VStack(alignment: .trailing, spacing: 2) {
+ Text(viewedAt, format: .dateTime.month(.abbreviated).day().year())
+ Text(viewedAt, format: .dateTime.hour().minute())
+ }
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .monospacedDigit()
+ } else {
+ Text("Date unavailable")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+
+ if showsNavigationIndicator {
+ Image(systemName: "chevron.forward")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.tertiary)
+ .accessibilityHidden(true)
+ }
+ }
+ .padding(.vertical, 9)
+ .accessibilityElement(children: .combine)
+ }
+
+ private var artworkURL: URL? {
+ guard let serverURL else {
+ return nil
+ }
+ return PlexURLBuilder.mediaURL(
+ serverURL: serverURL,
+ path: historyItem.posterPath(spoilerPolicy: settingsStore.episodeSpoilerPolicy)
+ )
+ }
+
+ private var transcodedArtworkURL: URL? {
+ guard let serverURL else {
+ return nil
+ }
+ return PlexURLBuilder.transcodedArtworkURL(
+ serverURL: serverURL,
+ path: historyItem.posterPath(spoilerPolicy: settingsStore.episodeSpoilerPolicy),
+ width: 92,
+ height: 128
+ )
+ }
+}
diff --git a/PlexBar/Views/PlexMediaHubShelf.swift b/PlexBar/Views/PlexMediaHubShelf.swift
new file mode 100644
index 0000000..8a375d5
--- /dev/null
+++ b/PlexBar/Views/PlexMediaHubShelf.swift
@@ -0,0 +1,206 @@
+import PlexModels
+import SwiftUI
+
+struct PlexMediaHubShelf: View {
+ let hub: PlexHub
+ let showAllRoute: PlexNavigationRoute?
+ @Bindable var browserStore: PlexBrowserStore
+ @Bindable var settingsStore: PlexSettingsStore
+ @Bindable var connectionStore: PlexConnectionStore
+ @Bindable var playerCoordinator: PlexPlayerCoordinator
+
+ var body: some View {
+ PlexMediaShelf(
+ title: hub.title,
+ items: hub.metadata,
+ artworkLayout: hub.prefersPosterArtwork ? .poster : .automatic,
+ allowsRemovalFromContinueWatching: hub.isContinueWatching,
+ showAllRoute: showAllRoute,
+ browserStore: browserStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator
+ )
+ }
+}
+
+struct PlexMediaShelf: View {
+ let title: String
+ let items: [PlexMediaItem]
+ let artworkLayout: PlexMediaPosterCard.ArtworkLayout
+ var allowsRemovalFromContinueWatching = false
+ let showAllRoute: PlexNavigationRoute?
+ @Bindable var browserStore: PlexBrowserStore
+ @Bindable var settingsStore: PlexSettingsStore
+ @Bindable var connectionStore: PlexConnectionStore
+ @Bindable var playerCoordinator: PlexPlayerCoordinator
+ @ScaledMetric(relativeTo: .body) private var cardWidth: CGFloat = 180
+ private let artworkPrefetcher = PlexArtworkPrefetcher.shared
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack(alignment: .firstTextBaseline) {
+ Text(title)
+ .font(.title2)
+ .fontWeight(.semibold)
+ .accessibilityAddTraits(.isHeader)
+
+ Spacer()
+
+ if let showAllRoute {
+ NavigationLink("Show All", value: showAllRoute)
+ }
+ }
+
+ ScrollView(.horizontal) {
+ LazyHStack(alignment: .top, spacing: 20) {
+ ForEach(items) { item in
+ NavigationLink(value: PlexNavigationRoute.media(PlexMediaRoute(item: item))) {
+ PlexMediaPosterCard(
+ item: item,
+ settingsStore: settingsStore,
+ serverURL: connectionStore.resolvedServerURL,
+ artworkLayout: artworkLayout
+ )
+ .frame(width: cardWidth, alignment: .topLeading)
+ }
+ .plexMediaContextMenu(
+ for: item,
+ allowsRemovalFromContinueWatching: allowsRemovalFromContinueWatching,
+ browserStore: browserStore,
+ playerCoordinator: playerCoordinator
+ )
+ .buttonStyle(.plain)
+ .task {
+ await prefetchArtwork(after: item)
+ }
+ }
+ }
+ }
+ }
+ .accessibilityElement(children: .contain)
+ .accessibilityLabel(title)
+ }
+
+ private func prefetchArtwork(after item: PlexMediaItem) async {
+ guard let itemIndex = items.firstIndex(where: { $0.id == item.id }) else {
+ return
+ }
+
+ let requests = items
+ .dropFirst(itemIndex + 1)
+ .prefix(6)
+ .compactMap {
+ PlexMediaPosterCard.prefetchRequest(
+ for: $0,
+ serverURL: connectionStore.resolvedServerURL,
+ token: settingsStore.trimmedServerToken,
+ clientContext: PlexClientContext(clientIdentifier: settingsStore.clientIdentifier),
+ artworkLayout: artworkLayout,
+ spoilerPolicy: settingsStore.episodeSpoilerPolicy
+ )
+ }
+ await artworkPrefetcher.prefetch(requests)
+ }
+}
+
+struct PlexMediaGridSection: View {
+ let title: String
+ let items: [PlexMediaItem]
+ let artworkLayout: PlexMediaPosterCard.ArtworkLayout
+ var allowsRemovalFromContinueWatching = false
+ var showAllRoute: PlexNavigationRoute? = nil
+ @Bindable var browserStore: PlexBrowserStore
+ @Bindable var settingsStore: PlexSettingsStore
+ @Bindable var connectionStore: PlexConnectionStore
+ @Bindable var playerCoordinator: PlexPlayerCoordinator
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ HStack(alignment: .firstTextBaseline) {
+ Text(title)
+ .font(.title2.weight(.semibold))
+ .accessibilityAddTraits(.isHeader)
+
+ Spacer()
+
+ if let showAllRoute {
+ NavigationLink("Show All", value: showAllRoute)
+ }
+ }
+
+ PlexMediaPosterGrid(
+ items: items,
+ artworkLayout: artworkLayout,
+ allowsRemovalFromContinueWatching: allowsRemovalFromContinueWatching,
+ browserStore: browserStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator
+ )
+ }
+ .accessibilityElement(children: .contain)
+ .accessibilityLabel(title)
+ }
+}
+
+struct PlexMediaPosterGrid: View {
+ let items: [PlexMediaItem]
+ let artworkLayout: PlexMediaPosterCard.ArtworkLayout
+ var allowsRemovalFromContinueWatching = false
+ @Bindable var browserStore: PlexBrowserStore
+ @Bindable var settingsStore: PlexSettingsStore
+ @Bindable var connectionStore: PlexConnectionStore
+ @Bindable var playerCoordinator: PlexPlayerCoordinator
+ private let artworkPrefetcher = PlexArtworkPrefetcher.shared
+
+ var body: some View {
+ LazyVGrid(
+ columns: PlexMediaPosterCard.standardGridColumns,
+ alignment: .leading,
+ spacing: 24
+ ) {
+ ForEach(items) { item in
+ NavigationLink(value: PlexNavigationRoute.media(PlexMediaRoute(item: item))) {
+ PlexMediaPosterCard(
+ item: item,
+ settingsStore: settingsStore,
+ serverURL: connectionStore.resolvedServerURL,
+ artworkLayout: artworkLayout
+ )
+ }
+ .plexMediaContextMenu(
+ for: item,
+ allowsRemovalFromContinueWatching: allowsRemovalFromContinueWatching,
+ browserStore: browserStore,
+ playerCoordinator: playerCoordinator
+ )
+ .buttonStyle(.plain)
+ .task {
+ await prefetchArtwork(after: item)
+ }
+ }
+ }
+ }
+
+ private func prefetchArtwork(after item: PlexMediaItem) async {
+ guard let itemIndex = items.firstIndex(where: { $0.id == item.id }) else {
+ return
+ }
+
+ let requests = items
+ .dropFirst(itemIndex + 1)
+ .prefix(6)
+ .compactMap {
+ PlexMediaPosterCard.prefetchRequest(
+ for: $0,
+ serverURL: connectionStore.resolvedServerURL,
+ token: settingsStore.trimmedServerToken,
+ clientContext: PlexClientContext(clientIdentifier: settingsStore.clientIdentifier),
+ artworkLayout: artworkLayout,
+ spoilerPolicy: settingsStore.episodeSpoilerPolicy
+ )
+ }
+ await artworkPrefetcher.prefetch(requests)
+ }
+}
diff --git a/PlexBar/Views/PlexMediaLogoView.swift b/PlexBar/Views/PlexMediaLogoView.swift
new file mode 100644
index 0000000..bf40785
--- /dev/null
+++ b/PlexBar/Views/PlexMediaLogoView.swift
@@ -0,0 +1,98 @@
+import SwiftUI
+
+struct PlexMediaLogoView: View {
+ @Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion
+ let imageURL: URL?
+ let fallbackTitle: String
+ let accessibilityLabel: String
+ let token: String
+ let clientContext: PlexClientContext
+ let maximumWidth: CGFloat
+ let maximumHeight: CGFloat
+ @State private var artwork: PlexArtworkPresentationState
+
+ init(
+ imageURL: URL?,
+ fallbackTitle: String,
+ accessibilityLabel: String? = nil,
+ token: String,
+ clientContext: PlexClientContext,
+ maximumWidth: CGFloat = 320,
+ maximumHeight: CGFloat = 112
+ ) {
+ self.imageURL = imageURL
+ self.fallbackTitle = fallbackTitle
+ self.accessibilityLabel = accessibilityLabel ?? fallbackTitle
+ self.token = token
+ self.clientContext = clientContext
+ self.maximumWidth = maximumWidth
+ self.maximumHeight = maximumHeight
+ _artwork = State(initialValue: PlexArtworkPresentationState(
+ primaryImageURL: imageURL,
+ token: token,
+ wantsPalette: false,
+ maximumPixelSize: 1_000
+ ))
+ }
+
+ var body: some View {
+ Group {
+ if let cgImage = artwork.cgImage {
+ let fittedSize = fittedLogoSize(for: cgImage)
+ Image(decorative: cgImage, scale: 1, orientation: .up)
+ .resizable()
+ .scaledToFit()
+ .frame(
+ width: fittedSize.width,
+ height: fittedSize.height,
+ alignment: .leading
+ )
+ .transition(.opacity)
+ } else {
+ Text(fallbackTitle)
+ .font(.system(size: 42, weight: .bold))
+ .lineLimit(2)
+ .minimumScaleFactor(0.72)
+ .frame(
+ width: maximumWidth,
+ alignment: .bottomLeading
+ )
+ .frame(maxHeight: maximumHeight, alignment: .bottomLeading)
+ .transition(.opacity)
+ }
+ }
+ .animation(
+ PlexMotion.contentReplacementAnimation(reduceMotion: accessibilityReduceMotion),
+ value: artwork.cgImage != nil
+ )
+ .frame(maxWidth: maximumWidth, alignment: .bottomLeading)
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(accessibilityLabel)
+ .task(id: requestKey) {
+ await artwork.load(
+ primaryImageURL: imageURL,
+ fallbackImageURL: nil,
+ token: token,
+ clientContext: clientContext,
+ wantsPalette: false,
+ maximumPixelSize: 1_000
+ )
+ }
+ }
+
+ private var requestKey: String {
+ [imageURL?.absoluteString, token, clientContext.clientIdentifier]
+ .compactMap { $0 }
+ .joined(separator: "|")
+ }
+
+ private func fittedLogoSize(for image: CGImage) -> CGSize {
+ guard image.width > 0, image.height > 0 else {
+ return CGSize(width: maximumWidth, height: maximumHeight)
+ }
+
+ let aspectRatio = CGFloat(image.width) / CGFloat(image.height)
+ let width = min(maximumWidth, maximumHeight * aspectRatio)
+ return CGSize(width: width, height: width / aspectRatio)
+ }
+}
diff --git a/PlexBar/Views/PlexMediaMetadataView.swift b/PlexBar/Views/PlexMediaMetadataView.swift
new file mode 100644
index 0000000..bd5ae3f
--- /dev/null
+++ b/PlexBar/Views/PlexMediaMetadataView.swift
@@ -0,0 +1,76 @@
+import PlexModels
+import SwiftUI
+
+struct PlexMediaMetadataView: View {
+ let presentation: PlexMediaMetadataPresentation
+
+ init(item: PlexMediaItem) {
+ presentation = PlexMediaMetadataPresentation(item: item)
+ }
+
+ var body: some View {
+ if !presentation.facts.isEmpty {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("Details")
+ #if os(tvOS)
+ .font(TVTypography.sectionTitle)
+ #else
+ .font(.headline)
+ #endif
+ .accessibilityAddTraits(.isHeader)
+
+ VStack(alignment: .leading, spacing: 8) {
+ ForEach(presentation.facts) { fact in
+ PlexMediaMetadataFactRow(fact: fact)
+ }
+ }
+ }
+ .accessibilityElement(children: .contain)
+ #if os(tvOS)
+ .focusable()
+ #endif
+ }
+ }
+}
+
+private struct PlexMediaMetadataFactRow: View {
+ let fact: PlexMediaMetadataFact
+ @ScaledMetric(relativeTo: .callout) private var labelWidth = 112.0
+
+ var body: some View {
+ HStack(alignment: .firstTextBaseline, spacing: 18) {
+ Text(fact.label)
+ .font(valueFont)
+ .foregroundStyle(.secondary)
+ .frame(width: resolvedLabelWidth, alignment: .trailing)
+ .accessibilityHidden(true)
+
+ Text(fact.value)
+ .font(valueFont)
+ #if !os(tvOS)
+ .textSelection(.enabled)
+ #endif
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .accessibilityHidden(true)
+ }
+ .accessibilityRepresentation {
+ Text("\(fact.label): \(fact.value)")
+ }
+ }
+
+ private var valueFont: Font {
+ #if os(tvOS)
+ TVTypography.metadata
+ #else
+ .callout
+ #endif
+ }
+
+ private var resolvedLabelWidth: CGFloat {
+ #if os(tvOS)
+ 160
+ #else
+ labelWidth
+ #endif
+ }
+}
diff --git a/PlexBar/Views/PlexMediaOrganizationSheets.swift b/PlexBar/Views/PlexMediaOrganizationSheets.swift
new file mode 100644
index 0000000..203b31c
--- /dev/null
+++ b/PlexBar/Views/PlexMediaOrganizationSheets.swift
@@ -0,0 +1,309 @@
+import PlexModels
+import SwiftUI
+
+enum PlexMediaOrganizationRequest: Identifiable {
+ case collection
+ case playlist
+
+ var id: String {
+ switch self {
+ case .collection: "collection"
+ case .playlist: "playlist"
+ }
+ }
+}
+
+struct PlexAddToCollectionSheet: View {
+ @Environment(\.dismiss) private var dismiss
+ let item: PlexMediaItem
+ let library: PlexLibrary
+ @Bindable var browserStore: PlexBrowserStore
+ @State private var newCollectionName = ""
+ @State private var activeCollectionID: String?
+ @State private var errorMessage: String?
+ @FocusState private var isNewCollectionNameFocused: Bool
+
+ var body: some View {
+ NavigationStack {
+ List {
+ Section("New Collection") {
+ HStack {
+ TextField("Name", text: $newCollectionName)
+ .focused($isNewCollectionNameFocused)
+ .onSubmit(createAndAdd)
+
+ Button("Create and Add", systemImage: "plus", action: createAndAdd)
+ .disabled(
+ newCollectionName.nilIfBlank == nil
+ || activeCollectionID != nil
+ || !browserStore.supportsCollectionManagement
+ )
+ }
+ }
+
+ Section("Collections") {
+ if browserStore.isLoadingCollections(in: library) && collections.isEmpty {
+ ProgressView()
+ .frame(maxWidth: .infinity, minHeight: 70)
+ .accessibilityLabel("Loading Collections")
+ } else if let errorMessage = browserStore.collectionsErrorMessage(in: library),
+ collections.isEmpty {
+ ContentUnavailableView {
+ Label("Couldn’t Load Collections", systemImage: "exclamationmark.triangle")
+ } description: {
+ Text(errorMessage)
+ } actions: {
+ Button("Try Again", action: reload)
+ }
+ } else if collections.isEmpty {
+ ContentUnavailableView("No Editable Collections", systemImage: "rectangle.stack")
+ } else {
+ ForEach(collections) { collection in
+ Button {
+ add(to: collection)
+ } label: {
+ HStack {
+ VStack(alignment: .leading, spacing: 3) {
+ Text(collection.title)
+ if let itemCountLabel = collection.itemCountLabel {
+ Text(itemCountLabel)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
+ Spacer()
+ if activeCollectionID == collection.ratingKey {
+ ProgressView()
+ .controlSize(.small)
+ }
+ }
+ .contentShape(.rect)
+ }
+ .disabled(activeCollectionID != nil)
+ }
+ }
+ }
+ }
+ .navigationTitle("Add to Collection")
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel", action: dismiss.callAsFunction)
+ .keyboardShortcut(.cancelAction)
+ }
+ }
+ }
+ .frame(minWidth: 480, minHeight: 440)
+ .onAppear { isNewCollectionNameFocused = true }
+ .task {
+ async let capabilities: Void = browserStore.loadLibraryProviderCapabilities()
+ await browserStore.loadCollections(in: library)
+ _ = await capabilities
+ }
+ .alert(
+ "Couldn’t Add to Collection",
+ isPresented: Binding(
+ get: { errorMessage != nil },
+ set: { isPresented in
+ if !isPresented {
+ errorMessage = nil
+ }
+ }
+ )
+ ) {
+ Button("OK") {}
+ } message: {
+ Text(errorMessage ?? "Unknown Plex error.")
+ }
+ }
+
+ private var collections: [PlexMediaItem] {
+ browserStore.editableCollections(in: library)
+ }
+
+ private func reload() {
+ Task {
+ await browserStore.loadCollections(in: library, forceRefresh: true)
+ }
+ }
+
+ private func add(to collection: PlexMediaItem) {
+ guard activeCollectionID == nil else {
+ return
+ }
+ activeCollectionID = collection.ratingKey
+ Task {
+ defer { activeCollectionID = nil }
+ do {
+ try await browserStore.add(item, to: collection, in: library)
+ dismiss()
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ private func createAndAdd() {
+ guard activeCollectionID == nil, newCollectionName.nilIfBlank != nil else {
+ return
+ }
+ activeCollectionID = "new"
+ Task {
+ defer { activeCollectionID = nil }
+ do {
+ try await browserStore.createCollection(
+ named: newCollectionName,
+ containing: item,
+ in: library
+ )
+ dismiss()
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+}
+
+struct PlexAddToPlaylistSheet: View {
+ @Environment(\.dismiss) private var dismiss
+ let item: PlexMediaItem
+ @Bindable var browserStore: PlexBrowserStore
+ @State private var newPlaylistName = ""
+ @State private var activePlaylistID: String?
+ @State private var errorMessage: String?
+ @FocusState private var isNewPlaylistNameFocused: Bool
+
+ var body: some View {
+ NavigationStack {
+ List {
+ Section("New Playlist") {
+ HStack {
+ TextField("Name", text: $newPlaylistName)
+ .focused($isNewPlaylistNameFocused)
+ .onSubmit(createPlaylist)
+
+ Button("Create", systemImage: "plus", action: createPlaylist)
+ .disabled(
+ newPlaylistName.nilIfBlank == nil
+ || activePlaylistID != nil
+ )
+ }
+ }
+
+ Section("Playlists") {
+ if browserStore.isLoadingPlaylists && playlists.isEmpty {
+ ProgressView()
+ .frame(maxWidth: .infinity, minHeight: 70)
+ .accessibilityLabel("Loading Playlists")
+ } else if let errorMessage = browserStore.playlistsErrorMessage,
+ playlists.isEmpty {
+ ContentUnavailableView {
+ Label("Couldn’t Load Playlists", systemImage: "exclamationmark.triangle")
+ } description: {
+ Text(errorMessage)
+ } actions: {
+ Button("Try Again", action: reload)
+ }
+ } else if playlists.isEmpty {
+ ContentUnavailableView("No Compatible Playlists", systemImage: "music.note.list")
+ } else {
+ ForEach(playlists) { playlist in
+ Button {
+ add(to: playlist)
+ } label: {
+ HStack {
+ VStack(alignment: .leading, spacing: 3) {
+ Text(playlist.title)
+ if let itemCountLabel = playlist.itemCountLabel {
+ Text(itemCountLabel)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
+ Spacer()
+ if activePlaylistID == playlist.ratingKey {
+ ProgressView()
+ .controlSize(.small)
+ }
+ }
+ .contentShape(.rect)
+ }
+ .disabled(activePlaylistID != nil)
+ }
+ }
+ }
+ }
+ .navigationTitle("Add to Playlist")
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel", action: dismiss.callAsFunction)
+ .keyboardShortcut(.cancelAction)
+ }
+ }
+ }
+ .frame(minWidth: 480, minHeight: 440)
+ .onAppear { isNewPlaylistNameFocused = true }
+ .task {
+ await browserStore.loadPlaylists()
+ }
+ .alert(
+ "Couldn’t Add to Playlist",
+ isPresented: Binding(
+ get: { errorMessage != nil },
+ set: { isPresented in
+ if !isPresented {
+ errorMessage = nil
+ }
+ }
+ )
+ ) {
+ Button("OK") {}
+ } message: {
+ Text(errorMessage ?? "Unknown Plex error.")
+ }
+ }
+
+ private var playlists: [PlexMediaItem] {
+ browserStore.editablePlaylists(for: item)
+ }
+
+ private func reload() {
+ Task {
+ await browserStore.loadPlaylists(forceRefresh: true)
+ }
+ }
+
+ private func add(to playlist: PlexMediaItem) {
+ guard activePlaylistID == nil else {
+ return
+ }
+ activePlaylistID = playlist.ratingKey
+ Task {
+ defer { activePlaylistID = nil }
+ do {
+ try await browserStore.add(item, to: playlist)
+ dismiss()
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ private func createPlaylist() {
+ guard activePlaylistID == nil, newPlaylistName.nilIfBlank != nil else {
+ return
+ }
+ activePlaylistID = "new"
+ Task {
+ defer { activePlaylistID = nil }
+ do {
+ try await browserStore.createPlaylist(
+ named: newPlaylistName,
+ containing: item
+ )
+ dismiss()
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+}
diff --git a/PlexBar/Views/PlexMediaPresentation.swift b/PlexBar/Views/PlexMediaPresentation.swift
new file mode 100644
index 0000000..cf07a61
--- /dev/null
+++ b/PlexBar/Views/PlexMediaPresentation.swift
@@ -0,0 +1,68 @@
+import PlexModels
+import SwiftUI
+
+extension PlexMediaItem {
+ var usesCinematicDetailHero: Bool {
+ guard let type = type?.lowercased() else {
+ return false
+ }
+ return ["movie", "episode", "clip"].contains(type)
+ }
+
+ var usesCinematicHierarchyHero: Bool {
+ guard let type = type?.lowercased() else {
+ return false
+ }
+ return ["show", "season"].contains(type)
+ }
+
+ var placeholderSymbol: String {
+ switch type?.lowercased() {
+ case "show", "season", "episode": "tv"
+ case "artist", "album", "track": "music.note"
+ case "photo", "photoalbum": "photo"
+ case "collection": "rectangle.stack"
+ case "playlist": playlistType == "audio" ? "music.note.list" : "play.square.stack"
+ case "playlistfolder": "folder"
+ default: "film"
+ }
+ }
+
+ var usesSquareArtwork: Bool {
+ guard let type = type?.lowercased() else {
+ return false
+ }
+ return [
+ "artist", "album", "track", "photoalbum", "collection", "playlist", "playlistfolder"
+ ].contains(type)
+ }
+
+ var usesLandscapeArtwork: Bool {
+ guard let type = type?.lowercased() else {
+ return false
+ }
+ return ["episode", "clip", "photo"].contains(type)
+ }
+
+ var detailArtworkHeight: CGFloat {
+ if usesLandscapeArtwork {
+ return 146
+ }
+ return usesSquareArtwork ? 260 : 390
+ }
+
+ var itemCountLabel: String? {
+ leafCount.map { "\($0.formatted()) \($0 == 1 ? "item" : "items")" }
+ }
+
+ var watchStateAccessibilityValue: String? {
+ if isWatched {
+ return "Watched"
+ }
+ if let progress {
+ return "\(progress.formatted(.percent.precision(.fractionLength(0)))) watched"
+ }
+ return supportsWatchedStateMutation ? "Unwatched" : nil
+ }
+
+}
diff --git a/PlexBar/Views/PlexMediaWatchStateIndicator.swift b/PlexBar/Views/PlexMediaWatchStateIndicator.swift
new file mode 100644
index 0000000..9325304
--- /dev/null
+++ b/PlexBar/Views/PlexMediaWatchStateIndicator.swift
@@ -0,0 +1,72 @@
+import SwiftUI
+
+struct PlexMediaWatchStateIndicator: View {
+ enum Scale {
+ case compact
+ case standard
+ case large
+
+ var badgeSize: CGFloat {
+ switch self {
+ case .compact: 14
+ case .standard: 18
+ case .large: 24
+ }
+ }
+
+ var symbolSize: CGFloat {
+ switch self {
+ case .compact: 7
+ case .standard: 9
+ case .large: 12
+ }
+ }
+
+ var edgeInset: CGFloat {
+ switch self {
+ case .compact: 4
+ case .standard: 6
+ case .large: 8
+ }
+ }
+
+ }
+
+ let isWatched: Bool
+ var scale: Scale = .standard
+
+ var body: some View {
+ Color.clear
+ .overlay(alignment: .topTrailing) {
+ if isWatched {
+ watchedBadge
+ .padding(scale.edgeInset)
+ }
+ }
+ .accessibilityHidden(true)
+ }
+
+ private var watchedBadge: some View {
+ Image(systemName: "checkmark")
+ .font(.system(size: scale.symbolSize, weight: .semibold))
+ .foregroundStyle(.black.opacity(0.88))
+ .frame(width: scale.badgeSize, height: scale.badgeSize)
+ .background(.white.opacity(0.96), in: Circle())
+ .overlay {
+ Circle()
+ .stroke(.black.opacity(0.72), lineWidth: 1)
+ }
+ }
+
+}
+
+extension View {
+ func plexWatchedIndicator(
+ isWatched: Bool,
+ scale: PlexMediaWatchStateIndicator.Scale = .standard
+ ) -> some View {
+ overlay {
+ PlexMediaWatchStateIndicator(isWatched: isWatched, scale: scale)
+ }
+ }
+}
diff --git a/PlexBar/Views/PlexPersonDetailsView.swift b/PlexBar/Views/PlexPersonDetailsView.swift
new file mode 100644
index 0000000..504f0d3
--- /dev/null
+++ b/PlexBar/Views/PlexPersonDetailsView.swift
@@ -0,0 +1,125 @@
+import PlexModels
+import SwiftUI
+
+struct PlexPersonDetailsView: View {
+ let route: PlexPersonRoute
+ @Bindable var browserStore: PlexBrowserStore
+ @Bindable var settingsStore: PlexSettingsStore
+ @Bindable var connectionStore: PlexConnectionStore
+ @Bindable var playerCoordinator: PlexPlayerCoordinator
+ @ScaledMetric(relativeTo: .body) private var portraitSize: CGFloat = 180
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 30) {
+ header
+
+ if isLoading, media.isEmpty {
+ ProgressView("Loading appearances…")
+ .frame(maxWidth: .infinity, minHeight: 160)
+ .accessibilityLabel("Loading appearances for \(displayName)")
+ } else if let errorMessage, media.isEmpty {
+ ContentUnavailableView {
+ Label("Couldn’t Load Appearances", systemImage: "exclamationmark.triangle")
+ } description: {
+ Text(errorMessage)
+ } actions: {
+ Button("Try Again", action: refresh)
+ }
+ .frame(maxWidth: 980, minHeight: 180)
+ } else if media.isEmpty {
+ ContentUnavailableView(
+ "No Appearances in This Library",
+ systemImage: "rectangle.stack"
+ )
+ .frame(maxWidth: 980, minHeight: 180)
+ } else {
+ PlexMediaGridSection(
+ title: "In Your Library",
+ items: media,
+ artworkLayout: .poster,
+ browserStore: browserStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator
+ )
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .scenePadding()
+ }
+ .navigationTitle(displayName)
+ .focusedSceneValue(
+ \.plexRefreshCommand,
+ PlexFocusedCommandAction(
+ title: "Reload \(displayName)",
+ isEnabled: !isLoading,
+ perform: refresh
+ )
+ )
+ .task(id: route) {
+ await browserStore.loadPerson(route)
+ }
+ .toolbar {
+ ToolbarItem {
+ Button("Reload \(displayName)", systemImage: "arrow.clockwise", action: refresh)
+ .disabled(isLoading)
+ }
+ }
+ }
+
+ private var header: some View {
+ HStack(alignment: .top, spacing: 24) {
+ PlexArtworkView(
+ primaryImageURL: portraitRequest?.url,
+ fallbackImageURL: nil,
+ token: portraitRequest?.token ?? "",
+ clientContext: PlexClientContext(clientIdentifier: settingsStore.clientIdentifier),
+ placeholderSymbol: "person.crop.square",
+ width: portraitSize,
+ height: portraitSize,
+ cornerRadius: 20
+ )
+
+ Text(displayName)
+ .font(.largeTitle.weight(.semibold))
+ .frame(maxWidth: 680, alignment: .leading)
+ }
+ .accessibilityElement(children: .combine)
+ .accessibilityAddTraits(.isHeader)
+ }
+
+ private var person: PlexTag? {
+ browserStore.person(for: route)
+ }
+
+ private var displayName: String {
+ person?.tag.nilIfBlank ?? route.name
+ }
+
+ private var media: [PlexMediaItem] {
+ browserStore.personMedia(for: route)
+ }
+
+ private var isLoading: Bool {
+ browserStore.isLoadingPerson(route)
+ }
+
+ private var errorMessage: String? {
+ browserStore.personErrorMessage(for: route)
+ }
+
+ private var portraitRequest: PlexImageRequest? {
+ PlexImageRequest(
+ path: person?.thumb?.nilIfBlank ?? route.thumb,
+ serverURL: connectionStore.resolvedServerURL,
+ serverToken: settingsStore.trimmedServerToken
+ )
+ }
+
+ private func refresh() {
+ Task {
+ await browserStore.loadPerson(route, forceRefresh: true)
+ }
+ }
+}
diff --git a/PlexBar/Views/PlexPhotoDetailStage.swift b/PlexBar/Views/PlexPhotoDetailStage.swift
new file mode 100644
index 0000000..57505c8
--- /dev/null
+++ b/PlexBar/Views/PlexPhotoDetailStage.swift
@@ -0,0 +1,64 @@
+import SwiftUI
+
+struct PlexPhotoDetailStage: View {
+ let presentation: PlexPhotoPresentation
+ let title: String
+ let serverURL: URL?
+ let token: String
+ let clientContext: PlexClientContext
+ @Environment(\.displayScale) private var displayScale
+
+ var body: some View {
+ GeometryReader { geometry in
+ let displaySize = presentation.fittedSize(in: geometry.size)
+ let requestSize = presentation.requestPixelSize(
+ for: displaySize,
+ displayScale: displayScale
+ )
+
+ PlexArtworkView(
+ primaryImageURL: photoURL(
+ width: Int(requestSize.width),
+ height: Int(requestSize.height)
+ ),
+ fallbackImageURL: fallbackURL,
+ token: token,
+ clientContext: clientContext,
+ placeholderSymbol: "photo",
+ width: displaySize.width,
+ height: displaySize.height,
+ cornerRadius: 12,
+ contentMode: .fit
+ )
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ .frame(maxWidth: 1_100)
+ .frame(height: 620)
+ .padding(12)
+ .background(.black.opacity(0.22), in: .rect(cornerRadius: 18))
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(title)
+ }
+
+ private var fallbackURL: URL? {
+ guard let serverURL else {
+ return nil
+ }
+ return PlexURLBuilder.mediaURL(
+ serverURL: serverURL,
+ path: presentation.fallbackArtworkPath
+ )
+ }
+
+ private func photoURL(width: Int, height: Int) -> URL? {
+ guard let serverURL else {
+ return nil
+ }
+ return PlexURLBuilder.transcodedPhotoURL(
+ serverURL: serverURL,
+ path: presentation.sourcePath,
+ width: width,
+ height: height
+ )
+ }
+}
diff --git a/PlexBar/Views/PlexPlayerHUD.swift b/PlexBar/Views/PlexPlayerHUD.swift
new file mode 100644
index 0000000..fdbfb92
--- /dev/null
+++ b/PlexBar/Views/PlexPlayerHUD.swift
@@ -0,0 +1,60 @@
+import SwiftUI
+
+struct PlexPlayerHUD: View {
+ let title: String
+ let systemImage: String
+ let dismiss: () -> Void
+ private let content: Content
+
+ init(
+ title: String,
+ systemImage: String,
+ dismiss: @escaping () -> Void,
+ @ViewBuilder content: () -> Content
+ ) {
+ self.title = title
+ self.systemImage = systemImage
+ self.dismiss = dismiss
+ self.content = content()
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ HStack(spacing: 12) {
+ Label(title, systemImage: systemImage)
+ .font(.title3.weight(.semibold))
+
+ Spacer(minLength: 24)
+
+ Button("Close", systemImage: "xmark", action: dismiss)
+ .labelStyle(.iconOnly)
+ .buttonStyle(.glass)
+ .controlSize(.large)
+ .keyboardShortcut(.cancelAction)
+ .help("Close \(title)")
+ }
+ .padding(.horizontal, 20)
+ .padding(.vertical, 16)
+
+ Divider()
+ .opacity(0.7)
+
+ content
+ }
+ .glassEffect(
+ .regular.tint(.black.opacity(0.025)),
+ in: .rect(cornerRadius: 24)
+ )
+ .overlay {
+ RoundedRectangle(cornerRadius: 24, style: .continuous)
+ .stroke(.white.opacity(0.16), lineWidth: 1)
+ .allowsHitTesting(false)
+ }
+ .shadow(color: .black.opacity(0.28), radius: 28, y: 12)
+ .fixedSize(horizontal: true, vertical: true)
+ .contentShape(.rect(cornerRadius: 24))
+ .onTapGesture { }
+ .accessibilityElement(children: .contain)
+ .accessibilityLabel(title)
+ }
+}
diff --git a/PlexBar/Views/PlexPlayerInfoInspector.swift b/PlexBar/Views/PlexPlayerInfoInspector.swift
new file mode 100644
index 0000000..5a955ec
--- /dev/null
+++ b/PlexBar/Views/PlexPlayerInfoInspector.swift
@@ -0,0 +1,134 @@
+import SwiftUI
+
+struct PlexPlayerPlaybackInfoHUD: View {
+ let session: PlexPlayerSessionModel
+
+ private var presentation: PlexPlaybackInfoPresentation {
+ PlexPlaybackInfoPresentation(
+ item: session.presentation.item,
+ deliveryLabel: session.playbackMethodLabel,
+ videoQualityLabel: session.presentation.plan.mediaKind == .video
+ ? session.presentation.videoQuality.label
+ : nil,
+ waitingReasonLabel: session.playbackWaitingReasonLabel,
+ deliveredMediaFacts: session.engine.mediaFacts,
+ playbackMetricFacts: session.playbackMetricDiagnosticFacts
+ )
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 20) {
+ header
+
+ Divider()
+
+ HStack(alignment: .top, spacing: 24) {
+ VStack(alignment: .leading, spacing: 22) {
+ PlexPlaybackInfoSection(
+ title: "Playback",
+ systemImage: "play.circle",
+ rows: presentation.playbackRows
+ )
+
+ if !presentation.videoRows.isEmpty {
+ PlexPlaybackInfoSection(
+ title: "Video",
+ systemImage: "film",
+ rows: presentation.videoRows
+ )
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .topLeading)
+
+ Divider()
+
+ VStack(alignment: .leading, spacing: 22) {
+ if !presentation.audioRows.isEmpty {
+ PlexPlaybackInfoSection(
+ title: "Audio",
+ systemImage: "waveform",
+ rows: presentation.audioRows
+ )
+ }
+
+ if !presentation.performanceRows.isEmpty {
+ PlexPlaybackInfoSection(
+ title: "Performance",
+ systemImage: "gauge.with.dots.needle.50percent",
+ rows: presentation.performanceRows
+ )
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .topLeading)
+ }
+ }
+ .padding(20)
+ .frame(width: 560)
+ .accessibilityElement(children: .contain)
+ .accessibilityLabel("Playback Info")
+ }
+
+ private var header: some View {
+ HStack(alignment: .firstTextBaseline, spacing: 20) {
+ VStack(alignment: .leading, spacing: 3) {
+ Text(presentation.item.title)
+ .font(.headline)
+ .lineLimit(2)
+ .textSelection(.enabled)
+
+ if let hierarchyLine = presentation.item.hierarchyLine {
+ Text(hierarchyLine)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ .textSelection(.enabled)
+ }
+ }
+
+ Spacer(minLength: 20)
+
+ VStack(alignment: .trailing, spacing: 3) {
+ Text(session.playbackStatusLabel)
+ .font(.subheadline.weight(.semibold))
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .accessibilityElement(children: .combine)
+ }
+
+}
+
+private struct PlexPlaybackInfoSection: View {
+ let title: String
+ let systemImage: String
+ let rows: [PlexPlaybackInfoPresentation.Row]
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ Label(title, systemImage: systemImage)
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(.primary)
+
+ VStack(alignment: .leading, spacing: 8) {
+ ForEach(rows) { row in
+ HStack(alignment: .firstTextBaseline, spacing: 12) {
+ Text(row.label)
+ .foregroundStyle(.secondary)
+ .frame(width: 106, alignment: .leading)
+ .accessibilityHidden(true)
+
+ Text(row.value)
+ .textSelection(.enabled)
+ .lineLimit(2)
+ .frame(maxWidth: .infinity, alignment: .trailing)
+ .accessibilityHidden(true)
+ }
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(row.label)
+ .accessibilityValue(row.value)
+ }
+ }
+ .font(.callout)
+ }
+ }
+}
diff --git a/PlexBar/Views/PlexPostPlayInspector.swift b/PlexBar/Views/PlexPostPlayInspector.swift
new file mode 100644
index 0000000..c0b2abb
--- /dev/null
+++ b/PlexBar/Views/PlexPostPlayInspector.swift
@@ -0,0 +1,267 @@
+import PlexModels
+import SwiftUI
+
+struct PlexPlaybackEndedOverlay: View {
+ let session: PlexPlayerSessionModel
+ let dismiss: () -> Void
+
+ var body: some View {
+ VStack(spacing: 0) {
+ header
+ content
+ }
+ .frame(width: 520, height: 460)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
+ .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
+ .shadow(radius: 24, y: 10)
+ .padding(32)
+ .accessibilityElement(children: .contain)
+ .accessibilityLabel("Playback Finished")
+ }
+
+ private var header: some View {
+ HStack {
+ Text(session.postPlayNextItem == nil ? "More to Watch" : "Up Next")
+ .font(.headline)
+
+ Spacer()
+
+ if session.isLoadingPostPlay {
+ ProgressView()
+ .controlSize(.small)
+ .accessibilityLabel("Loading post-play items")
+ }
+
+ Button("Refresh", systemImage: "arrow.clockwise", action: session.requestPostPlayRefresh)
+ .labelStyle(.iconOnly)
+ .disabled(session.isLoadingPostPlay || session.isLoading)
+ .help("Refresh Suggestions")
+
+ Button("Close", systemImage: "xmark", action: dismiss)
+ .labelStyle(.iconOnly)
+ .help("Close")
+ }
+ .padding(.horizontal)
+ .padding(.vertical, 12)
+ .background(.bar)
+ }
+
+ @ViewBuilder
+ private var content: some View {
+ if session.postPlayNextItem != nil || !session.postPlayHubs.isEmpty {
+ List {
+ if let nextItem = session.postPlayNextItem {
+ playingNextSection(nextItem)
+ }
+
+ ForEach(session.postPlayHubs) { hub in
+ let additionalItems = postPlayItems(in: hub)
+ if !additionalItems.isEmpty {
+ Section(hub.title) {
+ ForEach(additionalItems) { item in
+ PlexPostPlayItemRow(
+ item: item,
+ serverURL: session.artworkServerURL,
+ token: session.artworkToken,
+ clientContext: session.artworkClientContext,
+ action: { session.playPostPlayItem(item) }
+ )
+ .disabled(session.isLoading)
+ }
+ }
+ }
+ }
+
+ if let errorMessage = session.postPlayErrorMessage {
+ Section("Refresh Error") {
+ Text(errorMessage)
+ .foregroundStyle(.secondary)
+ Button("Try Again", action: session.requestPostPlayRefresh)
+ .disabled(session.isLoadingPostPlay || session.isLoading)
+ }
+ }
+ }
+ .listStyle(.inset)
+ } else {
+ if session.isLoadingPostPlay {
+ ProgressView("Loading…")
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .accessibilityLabel("Loading post-play items")
+ } else if let errorMessage = session.postPlayErrorMessage {
+ ContentUnavailableView {
+ Label("Couldn’t Load Suggestions", systemImage: "exclamationmark.triangle")
+ } description: {
+ Text(errorMessage)
+ } actions: {
+ Button("Try Again", action: session.requestPostPlayRefresh)
+ }
+ } else {
+ ContentUnavailableView("Nothing Else to Play", systemImage: "checkmark.circle")
+ }
+ }
+ }
+
+ private func postPlayItems(in hub: PlexHub) -> [PlexMediaItem] {
+ guard let nextItem = session.postPlayNextItem else {
+ return hub.metadata
+ }
+ return hub.metadata.filter {
+ !($0.ratingKey == nextItem.ratingKey
+ && ($0.playQueueItemID?.nilIfBlank == nil
+ || $0.playQueueItemID?.nilIfBlank == nextItem.playQueueItemID?.nilIfBlank))
+ }
+ }
+
+ private func playingNextSection(_ item: PlexMediaItem) -> some View {
+ Section("Playing Next") {
+ PlexPostPlayNextItem(
+ item: item,
+ serverURL: session.artworkServerURL,
+ token: session.artworkToken,
+ clientContext: session.artworkClientContext,
+ countdownTotalSeconds: session.postPlayCountdownTotalSeconds,
+ countdownRemainingSeconds: session.postPlayCountdownRemainingSeconds,
+ isLoading: session.isLoading,
+ play: session.playPostPlayNextItem,
+ cancelAutoplay: session.cancelPostPlayAutoplay
+ )
+ }
+ }
+}
+
+private struct PlexPostPlayNextItem: View {
+ let item: PlexMediaItem
+ let serverURL: URL?
+ let token: String
+ let clientContext: PlexClientContext
+ let countdownTotalSeconds: Int?
+ let countdownRemainingSeconds: Int?
+ let isLoading: Bool
+ let play: () -> Void
+ let cancelAutoplay: () -> Void
+ @ScaledMetric(relativeTo: .body) private var artworkWidth: CGFloat = 72
+ @ScaledMetric(relativeTo: .body) private var artworkHeight: CGFloat = 106
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack(alignment: .top, spacing: 12) {
+ PlexArtworkView(
+ primaryImageURL: artworkURL,
+ fallbackImageURL: nil,
+ token: token,
+ clientContext: clientContext,
+ placeholderSymbol: item.placeholderSymbol,
+ width: artworkWidth,
+ height: artworkHeight,
+ cornerRadius: 8
+ )
+ .accessibilityHidden(true)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(item.title)
+ .font(.headline)
+ .lineLimit(3)
+
+ if let subtitle = item.subtitle {
+ Text(subtitle)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ if let countdownTotalSeconds,
+ let countdownRemainingSeconds {
+ VStack(alignment: .leading, spacing: 5) {
+ ProgressView(
+ value: Double(countdownRemainingSeconds),
+ total: Double(countdownTotalSeconds)
+ )
+ .accessibilityLabel("Time Until Auto Play")
+ .accessibilityValue("\(countdownRemainingSeconds) seconds")
+
+ Text("Playing in \(countdownRemainingSeconds) seconds")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ HStack {
+ Button("Play Now", systemImage: "play.fill", action: play)
+ .buttonStyle(.borderedProminent)
+ .disabled(isLoading)
+
+ if countdownRemainingSeconds != nil {
+ Button("Cancel Auto Play", action: cancelAutoplay)
+ .disabled(isLoading)
+ }
+ }
+ }
+ .padding(.vertical, 6)
+ .accessibilityElement(children: .contain)
+ }
+
+ private var artworkURL: URL? {
+ guard let serverURL else {
+ return nil
+ }
+ return PlexURLBuilder.mediaURL(serverURL: serverURL, path: item.posterArtworkPath)
+ }
+}
+
+private struct PlexPostPlayItemRow: View {
+ let item: PlexMediaItem
+ let serverURL: URL?
+ let token: String
+ let clientContext: PlexClientContext
+ let action: () -> Void
+ @ScaledMetric(relativeTo: .body) private var artworkWidth: CGFloat = 42
+ @ScaledMetric(relativeTo: .body) private var artworkHeight: CGFloat = 62
+
+ var body: some View {
+ Button(action: action) {
+ HStack(spacing: 10) {
+ PlexArtworkView(
+ primaryImageURL: artworkURL,
+ fallbackImageURL: nil,
+ token: token,
+ clientContext: clientContext,
+ placeholderSymbol: item.placeholderSymbol,
+ width: artworkWidth,
+ height: artworkHeight,
+ cornerRadius: 6
+ )
+ .accessibilityHidden(true)
+
+ VStack(alignment: .leading, spacing: 3) {
+ Text(item.title)
+ .font(.body)
+ .lineLimit(2)
+
+ if let subtitle = item.subtitle {
+ Text(subtitle)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .contentShape(.rect)
+ }
+ .buttonStyle(.plain)
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel([item.title, item.subtitle].compactMap { $0 }.joined(separator: ", "))
+ .accessibilityHint("Starts this post-play item.")
+ .listRowInsets(EdgeInsets(top: 6, leading: 8, bottom: 6, trailing: 8))
+ }
+
+ private var artworkURL: URL? {
+ guard let serverURL else {
+ return nil
+ }
+ return PlexURLBuilder.mediaURL(serverURL: serverURL, path: item.posterArtworkPath)
+ }
+}
diff --git a/PlexBar/Views/PlexRelatedContentView.swift b/PlexBar/Views/PlexRelatedContentView.swift
new file mode 100644
index 0000000..82ca480
--- /dev/null
+++ b/PlexBar/Views/PlexRelatedContentView.swift
@@ -0,0 +1,263 @@
+import PlexModels
+import SwiftUI
+
+struct PlexRelatedContentView: View {
+ let item: PlexMediaItem
+ @Bindable var browserStore: PlexBrowserStore
+ @Bindable var settingsStore: PlexSettingsStore
+ @Bindable var connectionStore: PlexConnectionStore
+ @Bindable var playerCoordinator: PlexPlayerCoordinator
+
+ var body: some View {
+ Group {
+ if isLoading, hubs.isEmpty {
+ ProgressView("Loading Related Content")
+ .frame(maxWidth: 980, minHeight: 120)
+ .accessibilityLabel("Loading Related Content")
+ } else if let errorMessage, hubs.isEmpty {
+ ContentUnavailableView {
+ Label("Couldn’t Load Related Content", systemImage: "exclamationmark.triangle")
+ } description: {
+ Text(errorMessage)
+ } actions: {
+ Button("Try Again", action: refresh)
+ }
+ .frame(maxWidth: 980, minHeight: 180)
+ } else if !hubs.isEmpty {
+ LazyVStack(alignment: .leading, spacing: 30) {
+ if let errorMessage {
+ HStack(spacing: 10) {
+ Label("Related Content Didn’t Refresh", systemImage: "exclamationmark.triangle")
+ Text(errorMessage)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ Spacer()
+ Button("Try Again", action: refresh)
+ }
+ .font(.callout)
+ .frame(maxWidth: 980, alignment: .leading)
+ }
+
+ ForEach(hubs) { hub in
+ PlexMediaHubShelf(
+ hub: hub,
+ showAllRoute: showAllRoute(for: hub),
+ browserStore: browserStore,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore,
+ playerCoordinator: playerCoordinator
+ )
+ }
+ }
+ .overlay(alignment: .top) {
+ if isLoading {
+ ProgressView()
+ .progressViewStyle(.linear)
+ .accessibilityLabel("Refreshing Related Content")
+ }
+ }
+ }
+ }
+ }
+
+ private var hubs: [PlexHub] {
+ browserStore.relatedHubs(for: item)
+ }
+
+ private var isLoading: Bool {
+ browserStore.isLoadingRelatedContent(for: item)
+ }
+
+ private var errorMessage: String? {
+ browserStore.relatedContentErrorMessage(for: item)
+ }
+
+ private func refresh() {
+ Task {
+ await browserStore.loadRelatedContent(for: item, forceRefresh: true)
+ }
+ }
+
+ private func showAllRoute(for hub: PlexHub) -> PlexNavigationRoute? {
+ let totalSize = hub.totalSize ?? hub.size ?? hub.metadata.count
+ guard hub.more || totalSize > hub.metadata.count,
+ let route = PlexRelatedHubRoute(sourceItem: item, hub: hub) else {
+ return nil
+ }
+ return .relatedHub(route)
+ }
+}
+
+struct PlexRelatedHubItemsView: View {
+ let route: PlexRelatedHubRoute
+ let hub: PlexHub
+ @Bindable var browserStore: PlexBrowserStore
+ @Bindable var settingsStore: PlexSettingsStore
+ @Bindable var connectionStore: PlexConnectionStore
+ @Bindable var playerCoordinator: PlexPlayerCoordinator
+ private let artworkPrefetcher = PlexArtworkPrefetcher.shared
+
+ var body: some View {
+ Group {
+ if isLoading, items.isEmpty {
+ ProgressView()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .accessibilityLabel("Loading \(hub.title)")
+ } else if let errorMessage, items.isEmpty {
+ ContentUnavailableView {
+ Label("Couldn’t Load \(hub.title)", systemImage: "exclamationmark.triangle")
+ } description: {
+ Text(errorMessage)
+ } actions: {
+ Button("Try Again", action: refresh)
+ }
+ } else if items.isEmpty {
+ ContentUnavailableView("No Items", systemImage: "rectangle.stack")
+ } else {
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 20) {
+ if let errorMessage {
+ PlexRelatedHubErrorRow(
+ message: errorMessage,
+ retry: retryPageLoad
+ )
+ }
+
+ LazyVGrid(
+ columns: PlexMediaPosterCard.standardGridColumns,
+ alignment: .leading,
+ spacing: 24
+ ) {
+ ForEach(items) { item in
+ NavigationLink(value: PlexNavigationRoute.media(PlexMediaRoute(item: item))) {
+ PlexMediaPosterCard(
+ item: item,
+ settingsStore: settingsStore,
+ serverURL: connectionStore.resolvedServerURL,
+ artworkLayout: hub.prefersPosterArtwork ? .poster : .automatic
+ )
+ }
+ .plexMediaContextMenu(
+ for: item,
+ browserStore: browserStore,
+ playerCoordinator: playerCoordinator
+ )
+ .buttonStyle(.plain)
+ .task {
+ await prefetchArtwork(after: item)
+ await browserStore.loadMoreRelatedHubItemsIfNeeded(
+ for: route,
+ currentItem: item
+ )
+ }
+ }
+
+ if isLoading {
+ ProgressView()
+ .frame(maxWidth: .infinity, minHeight: 80)
+ .accessibilityLabel("Loading more \(hub.title)")
+ }
+ }
+ }
+ .scenePadding()
+ }
+ }
+ }
+ .overlay(alignment: .top) {
+ if isLoading, !items.isEmpty {
+ ProgressView()
+ .progressViewStyle(.linear)
+ .accessibilityLabel("Refreshing \(hub.title)")
+ }
+ }
+ .navigationTitle(hub.title)
+ .focusedSceneValue(
+ \.plexRefreshCommand,
+ PlexFocusedCommandAction(
+ title: "Refresh \(hub.title)",
+ isEnabled: !isLoading,
+ perform: refresh
+ )
+ )
+ .task(id: route) {
+ await browserStore.loadRelatedHubItems(for: route)
+ }
+ .toolbar {
+ ToolbarItem {
+ Button("Refresh \(hub.title)", systemImage: "arrow.clockwise", action: refresh)
+ .disabled(isLoading)
+ }
+ }
+ }
+
+ private var items: [PlexMediaItem] {
+ browserStore.relatedHubItems(for: route)
+ }
+
+ private var isLoading: Bool {
+ browserStore.isLoadingRelatedHubItems(for: route)
+ }
+
+ private var errorMessage: String? {
+ browserStore.relatedHubItemsErrorMessage(for: route)
+ }
+
+ private func refresh() {
+ Task {
+ await browserStore.loadRelatedHubItems(for: route, forceRefresh: true)
+ }
+ }
+
+ private func retryPageLoad() {
+ guard let lastItem = items.last,
+ browserStore.hasMoreRelatedHubItems(for: route) else {
+ refresh()
+ return
+ }
+ Task {
+ await browserStore.loadMoreRelatedHubItemsIfNeeded(
+ for: route,
+ currentItem: lastItem
+ )
+ }
+ }
+
+ private func prefetchArtwork(after item: PlexMediaItem) async {
+ let currentItems = items
+ guard let itemIndex = currentItems.firstIndex(where: { $0.id == item.id }) else {
+ return
+ }
+
+ let requests = currentItems
+ .dropFirst(itemIndex + 1)
+ .prefix(6)
+ .compactMap {
+ PlexMediaPosterCard.prefetchRequest(
+ for: $0,
+ serverURL: connectionStore.resolvedServerURL,
+ token: settingsStore.trimmedServerToken,
+ clientContext: PlexClientContext(clientIdentifier: settingsStore.clientIdentifier),
+ artworkLayout: hub.prefersPosterArtwork ? .poster : .automatic,
+ spoilerPolicy: settingsStore.episodeSpoilerPolicy
+ )
+ }
+ await artworkPrefetcher.prefetch(requests)
+ }
+}
+
+private struct PlexRelatedHubErrorRow: View {
+ let message: String
+ let retry: () -> Void
+
+ var body: some View {
+ HStack(alignment: .firstTextBaseline, spacing: 12) {
+ Label("Couldn’t Update Related Content", systemImage: "exclamationmark.triangle")
+ Text(message)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ Spacer()
+ Button("Try Again", action: retry)
+ }
+ .accessibilityElement(children: .contain)
+ }
+}
diff --git a/PlexBar/Views/PlexSeasonPicker.swift b/PlexBar/Views/PlexSeasonPicker.swift
new file mode 100644
index 0000000..fa3438e
--- /dev/null
+++ b/PlexBar/Views/PlexSeasonPicker.swift
@@ -0,0 +1,43 @@
+import PlexModels
+import SwiftUI
+
+struct PlexSeasonPicker: View {
+ let seasons: [PlexMediaItem]
+ @Binding var selection: String?
+
+ var body: some View {
+ Menu {
+ Picker("Season", selection: $selection) {
+ ForEach(seasons) { season in
+ Text(season.title).tag(Optional(season.id))
+ }
+ }
+ .pickerStyle(.inline)
+ } label: {
+ #if os(tvOS)
+ HStack(spacing: 12) {
+ Text(selectedTitle)
+ .font(TVTypography.sectionTitle)
+ Image(systemName: "chevron.down")
+ .font(TVTypography.metadata)
+ }
+ #else
+ Text(selectedTitle)
+ .font(.title2.weight(.semibold))
+ #endif
+ }
+ #if os(tvOS)
+ .buttonStyle(.borderless)
+ #else
+ .menuStyle(.borderlessButton)
+ #endif
+ .fixedSize()
+ .accessibilityLabel("Season")
+ .accessibilityValue(selectedTitle)
+ .accessibilityIdentifier("season-picker")
+ }
+
+ private var selectedTitle: String {
+ seasons.first { $0.id == selection }?.title ?? "Season"
+ }
+}
diff --git a/PlexBar/Views/PlexStarRatingPicker.swift b/PlexBar/Views/PlexStarRatingPicker.swift
new file mode 100644
index 0000000..bd96037
--- /dev/null
+++ b/PlexBar/Views/PlexStarRatingPicker.swift
@@ -0,0 +1,137 @@
+import SwiftUI
+
+struct PlexStarRatingPicker: View {
+ let serverValue: Double?
+ let isDisabled: Bool
+ let onSelect: (Double) -> Void
+
+ @ScaledMetric(relativeTo: .title3) private var starSize = 20.0
+ @ScaledMetric(relativeTo: .title3) private var starSpacing = 3.0
+ @State private var previewStars: Double?
+
+ private var controlWidth: Double {
+ (starSize * Double(PlexPersonalRating.starCount))
+ + (starSpacing * Double(PlexPersonalRating.starCount - 1))
+ }
+
+ private var selectedStars: Double {
+ PlexPersonalRating.stars(fromServerValue: serverValue) ?? 0
+ }
+
+ private var displayedStars: Double {
+ previewStars ?? selectedStars
+ }
+
+ var body: some View {
+ HStack(spacing: starSpacing) {
+ ForEach(0.. some View {
+ let fill = min(max(displayedStars - Double(index), 0), 1)
+
+ return Image(systemName: "star")
+ .resizable()
+ .scaledToFit()
+ .foregroundStyle(.secondary)
+ .overlay(alignment: .leading) {
+ Image(systemName: "star.fill")
+ .resizable()
+ .scaledToFit()
+ .foregroundStyle(.tint)
+ .frame(width: starSize, height: starSize)
+ .mask(alignment: .leading) {
+ Rectangle()
+ .frame(width: starSize * fill)
+ }
+ }
+ .frame(width: starSize, height: starSize)
+ }
+
+ private func updatePreview(_ phase: HoverPhase) {
+ guard !isDisabled else {
+ previewStars = nil
+ return
+ }
+
+ switch phase {
+ case let .active(location):
+ previewStars = PlexPersonalRating.stars(
+ at: location.x,
+ controlWidth: controlWidth
+ )
+ case .ended:
+ previewStars = nil
+ }
+ }
+
+ private func selectRating(_ value: SpatialTapGesture.Value) {
+ guard !isDisabled,
+ let stars = PlexPersonalRating.stars(
+ at: value.location.x,
+ controlWidth: controlWidth
+ ),
+ let selectedValue = PlexPersonalRating.serverValue(fromStars: stars) else {
+ return
+ }
+ onSelect(selectedValue)
+ }
+
+ private func adjustRating(by step: Int) -> KeyPress.Result {
+ guard !isDisabled else {
+ return .ignored
+ }
+ submitAdjustment(by: step)
+ return .handled
+ }
+
+ private func submitAdjustment(by step: Int) {
+ guard !isDisabled,
+ let adjustedValue = PlexPersonalRating.adjustedServerValue(
+ from: serverValue,
+ by: step
+ ) else {
+ return
+ }
+ onSelect(adjustedValue)
+ }
+}
diff --git a/PlexBar/Views/PlexTVShowDetailView.swift b/PlexBar/Views/PlexTVShowDetailView.swift
new file mode 100644
index 0000000..833b65b
--- /dev/null
+++ b/PlexBar/Views/PlexTVShowDetailView.swift
@@ -0,0 +1,515 @@
+import PlexModels
+import SwiftUI
+
+struct PlexTVShowDetailView: View {
+ @Environment(PlexDownloadsStore.self) private var downloadsStore
+ @Bindable var browserStore: PlexBrowserStore
+ @Bindable var historyStore: PlexHistoryStore
+ @Bindable var settingsStore: PlexSettingsStore
+ @Bindable var connectionStore: PlexConnectionStore
+ @Bindable var playerCoordinator: PlexPlayerCoordinator
+ let refreshesInitialMetadata: Bool
+ @State private var show: PlexMediaItem
+ @State private var hierarchyShow: PlexMediaItem
+ @State private var selectedSeasonID: String?
+ @State private var selectedEpisode: PlexMediaItem?
+ @State private var episodeSeriesCast: [PlexTag] = []
+ @State private var isPreparingPlayback = false
+ @State private var isLoadingEpisode = false
+ @State private var playbackErrorMessage: String?
+ @State private var downloadErrorMessage: String?
+
+ init(
+ initialShow: PlexMediaItem,
+ initialEpisode: PlexMediaItem? = nil,
+ browserStore: PlexBrowserStore,
+ historyStore: PlexHistoryStore,
+ settingsStore: PlexSettingsStore,
+ connectionStore: PlexConnectionStore,
+ playerCoordinator: PlexPlayerCoordinator,
+ refreshesInitialMetadata: Bool
+ ) {
+ self.browserStore = browserStore
+ self.historyStore = historyStore
+ self.settingsStore = settingsStore
+ self.connectionStore = connectionStore
+ self.playerCoordinator = playerCoordinator
+ self.refreshesInitialMetadata = refreshesInitialMetadata
+ _show = State(initialValue: initialShow)
+ _hierarchyShow = State(initialValue: initialShow)
+ _selectedEpisode = State(initialValue: initialEpisode)
+ }
+
+ var body: some View {
+ ZStack(alignment: .topLeading) {
+ Color(nsColor: .windowBackgroundColor)
+ .ignoresSafeArea()
+
+ ScrollView {
+ VStack(alignment: .leading, spacing: 0) {
+ showHero
+
+ VStack(alignment: .leading, spacing: 30) {
+ episodeBrowser
+
+ if let selectedEpisode {
+ PlexCastAndCrewView(
+ item: selectedEpisode,
+ episodeSeriesCast: episodeSeriesCast,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore
+ )
+
+ PlexMediaMetadataView(item: selectedEpisode)
+ .frame(maxWidth: 980, alignment: .leading)
+ } else {
+ PlexCastAndCrewView(
+ item: show,
+ settingsStore: settingsStore,
+ connectionStore: connectionStore
+ )
+
+ PlexMediaMetadataView(item: show)
+ .frame(maxWidth: 980, alignment: .leading)
+ }
+ }
+ .frame(maxWidth: 1_180, alignment: .leading)
+ .scenePadding()
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+ .navigationTitle(show.title)
+ .focusedSceneValue(
+ \.plexRefreshCommand,
+ PlexFocusedCommandAction(
+ title: "Reload \(show.title)",
+ isEnabled: !isReloading,
+ perform: refresh
+ )
+ )
+ .task(id: show.ratingKey) {
+ await loadShow()
+ }
+ .task(id: selectedSeasonID) {
+ await loadSelectedSeason()
+ }
+ .toolbar {
+ ToolbarItem {
+ Button("Reload \(show.title)", systemImage: "arrow.clockwise", action: refresh)
+ .disabled(isReloading)
+ }
+ }
+ .alert(
+ "Playback Error",
+ isPresented: Binding(
+ get: { playbackErrorMessage != nil },
+ set: { if !$0 { playbackErrorMessage = nil } }
+ )
+ ) {
+ Button("OK") {}
+ } message: {
+ Text(playbackErrorMessage ?? "Unknown playback error.")
+ }
+ .alert(
+ "Download Error",
+ isPresented: Binding(
+ get: { downloadErrorMessage != nil },
+ set: { if !$0 { downloadErrorMessage = nil } }
+ )
+ ) {
+ Button("OK") {}
+ } message: {
+ Text(downloadErrorMessage ?? "Unknown download error.")
+ }
+ }
+
+ private var showHero: some View {
+ PlexCinematicMediaHero(
+ primaryImageURL: heroArtworkURL,
+ fallbackImageURL: posterURL,
+ clearLogoURL: clearLogoURL,
+ title: show.title,
+ logoAccessibilityLabel: show.images.first {
+ $0.type.caseInsensitiveCompare("clearLogo") == .orderedSame
+ }?.alt,
+ token: settingsStore.trimmedServerToken,
+ clientContext: clientContext,
+ placeholderSymbol: show.placeholderSymbol,
+ playbackTitle: selectedEpisode?.title,
+ ratingsItem: show,
+ hasResumePosition: selectedEpisode?.hasResumePosition == true,
+ resumeProgress: selectedEpisode?.progress,
+ isPreparingPlayback: isPreparingPlayback,
+ isPlaybackEnabled: selectedEpisode?.defaultPlaybackSource != nil,
+ preparePlayback: preparePlayback
+ ) {
+ if let selectedEpisode {
+ episodeActions(for: selectedEpisode)
+ }
+ } details: {
+ episodeSummary
+ }
+ }
+
+ @ViewBuilder
+ private var episodeSummary: some View {
+ if let selectedEpisode {
+ VStack(alignment: .leading, spacing: 10) {
+ Text(episodeHeading(selectedEpisode))
+ .font(.title2.weight(.semibold))
+ .lineLimit(2)
+ .accessibilityAddTraits(.isHeader)
+
+ PlexMediaFactsView(
+ presentation: PlexMediaSummaryPresentation(item: selectedEpisode).episodeFactsPresentation,
+ genres: PlexMediaSummaryPresentation(item: show).genres
+ )
+ .font(.headline.weight(.medium))
+ .foregroundStyle(.white.opacity(0.72))
+
+ if let summary = PlexEpisodeSpoilerPresentation(
+ item: selectedEpisode,
+ policy: settingsStore.episodeSpoilerPolicy
+ ).summary {
+ PlexMediaDescriptionView(title: episodeHeading(selectedEpisode), summary: summary)
+ }
+
+ }
+ } else if isLoadingEpisode || browserStore.isLoadingChildren(of: hierarchyShow) {
+ ProgressView("Loading episodes…")
+ .tint(.white)
+ } else {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("No Episodes")
+ .font(.title2.weight(.semibold))
+ Text("Plex did not return any episodes for this show.")
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func episodeActions(for episode: PlexMediaItem) -> some View {
+ let binding = Binding(
+ get: { selectedEpisode ?? episode },
+ set: { selectedEpisode = $0 }
+ )
+
+ GlassEffectContainer(spacing: 10) {
+ HStack(spacing: 10) {
+ PlexWatchedStateButton(item: binding, browserStore: browserStore)
+ .plexCinematicUtilityButton()
+
+ if showsDownloadControl(for: episode) {
+ downloadButton(for: episode)
+ .plexCinematicUtilityButton()
+ }
+
+ PlexPersonalRatingMenu(item: binding, browserStore: browserStore)
+ .plexCinematicUtilityButton()
+ }
+ .labelStyle(.iconOnly)
+ }
+ }
+
+ private var episodeBrowser: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ HStack(spacing: 12) {
+ if seasons.isEmpty {
+ Text("Episodes")
+ .font(.title2.weight(.semibold))
+ .accessibilityAddTraits(.isHeader)
+ } else {
+ PlexSeasonPicker(seasons: seasons, selection: $selectedSeasonID)
+ }
+ }
+
+ if isLoadingEpisode && episodes.isEmpty {
+ ProgressView("Loading episodes…")
+ .frame(maxWidth: .infinity, minHeight: 145)
+ } else if episodes.isEmpty {
+ ContentUnavailableView("No Episodes", systemImage: "tv")
+ .frame(maxWidth: .infinity, minHeight: 145)
+ } else {
+ ScrollView(.horizontal) {
+ LazyHStack(alignment: .top, spacing: 16) {
+ ForEach(episodes) { episode in
+ episodeButton(episode)
+ }
+ }
+ .scrollTargetLayout()
+ }
+ .scrollIndicators(.hidden)
+ .scrollTargetBehavior(.viewAligned)
+ }
+ }
+ }
+
+ private func episodeButton(_ episode: PlexMediaItem) -> some View {
+ let isSelected = selectedEpisode?.ratingKey == episode.ratingKey
+ let spoiler = PlexEpisodeSpoilerPresentation(
+ item: episode,
+ policy: settingsStore.episodeSpoilerPolicy
+ )
+
+ return Button {
+ Task { await selectEpisode(episode) }
+ } label: {
+ VStack(alignment: .leading, spacing: 7) {
+ PlexArtworkView(
+ primaryImageURL: mediaURL(path: spoiler.thumbnailPath),
+ fallbackImageURL: spoiler.isProtected ? nil : posterURL,
+ token: settingsStore.trimmedServerToken,
+ clientContext: clientContext,
+ placeholderSymbol: spoiler.isProtected ? "eye.slash" : episode.placeholderSymbol,
+ width: 208,
+ height: 117,
+ cornerRadius: 10
+ )
+ .overlay {
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
+ .stroke(isSelected ? Color.accentColor : .clear, lineWidth: 3)
+ }
+ .overlay(alignment: .bottomLeading) {
+ if let progress = episode.progress {
+ ProgressView(value: progress)
+ .tint(.accentColor)
+ .padding(.horizontal, 7)
+ .padding(.bottom, 5)
+ .accessibilityHidden(true)
+ }
+ }
+ .plexWatchedIndicator(isWatched: episode.isWatched)
+
+ Text(episodeCardTitle(episode))
+ .font(.headline)
+ .lineLimit(1)
+
+ if let duration = episode.formattedDuration {
+ Text(duration)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .frame(width: 208, alignment: .leading)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(episodeHeading(episode))
+ .accessibilityValue(episode.watchStateAccessibilityValue ?? "")
+ .accessibilityAddTraits(isSelected ? .isSelected : [])
+ }
+
+ @ViewBuilder
+ private func downloadButton(for episode: PlexMediaItem) -> some View {
+ if downloadsStore.containsDownload(for: episode) {
+ Button("Downloaded", systemImage: "checkmark.circle.fill") {}
+ .disabled(true)
+ } else if downloadsStore.isDownloading(episode) {
+ Button("Downloading", systemImage: "arrow.down.circle") {}
+ .disabled(true)
+ } else {
+ Button("Download", systemImage: "arrow.down.circle") {
+ prepareDownload(episode)
+ }
+ .disabled(episode.defaultPlaybackSource == nil)
+ }
+ }
+
+ private var seasons: [PlexMediaItem] {
+ browserStore.children(of: hierarchyShow).filter { $0.type?.lowercased() == "season" }
+ }
+
+ private var selectedSeason: PlexMediaItem? {
+ seasons.first { $0.id == selectedSeasonID }
+ }
+
+ private var episodes: [PlexMediaItem] {
+ if let selectedSeason {
+ return browserStore.children(of: selectedSeason).filter {
+ $0.type?.lowercased() == "episode"
+ }
+ }
+ return browserStore.children(of: hierarchyShow).filter {
+ $0.type?.lowercased() == "episode"
+ }
+ }
+
+ private var isReloading: Bool {
+ browserStore.isLoadingChildren(of: hierarchyShow) || isLoadingEpisode
+ }
+
+ private var clientContext: PlexClientContext {
+ PlexClientContext(clientIdentifier: settingsStore.clientIdentifier)
+ }
+
+ private var activeConnectionKind: PlexConnectionKind? {
+ connectionStore.activeConnection?.kind ?? settingsStore.cachedConnectionKind
+ }
+
+ private var heroArtworkURL: URL? {
+ mediaURL(path: show.art?.nilIfBlank)
+ }
+
+ private var posterURL: URL? {
+ mediaURL(path: show.posterArtworkPath)
+ }
+
+ private var clearLogoURL: URL? {
+ mediaURL(path: show.clearLogoPath)
+ }
+
+ private func mediaURL(path: String?) -> URL? {
+ guard let serverURL = connectionStore.resolvedServerURL else {
+ return nil
+ }
+ return PlexURLBuilder.mediaURL(serverURL: serverURL, path: path)
+ }
+
+ private func episodeHeading(_ episode: PlexMediaItem) -> String {
+ PlexMediaSummaryPresentation(item: episode).episodeHeading
+ }
+
+ private func episodeCardTitle(_ episode: PlexMediaItem) -> String {
+ if let index = episode.index {
+ return "\(index). \(episode.title)"
+ }
+ return episode.title
+ }
+
+ private func showsDownloadControl(for episode: PlexMediaItem) -> Bool {
+ downloadsStore.containsDownload(for: episode)
+ || downloadsStore.isDownloading(episode)
+ || downloadsStore.canCreateDownload(for: episode)
+ }
+
+ private func loadShow(forceRefresh: Bool = false) async {
+ async let capabilityLoad: Void = browserStore.loadLibraryProviderCapabilities()
+
+ if refreshesInitialMetadata || forceRefresh {
+ let details = await browserStore.details(for: show)
+ guard !Task.isCancelled else { return }
+ hierarchyShow = hierarchyShow.hierarchyRequestItem(afterRefreshingWith: details)
+ show = details
+ }
+
+ await browserStore.loadChildren(of: hierarchyShow, forceRefresh: forceRefresh)
+ guard !Task.isCancelled else { return }
+
+ if seasons.isEmpty {
+ selectedSeasonID = nil
+ if selectedEpisode == nil || forceRefresh {
+ await selectEpisode(episodes.first)
+ }
+ } else if selectedSeasonID == nil || !seasons.contains(where: { $0.id == selectedSeasonID }) {
+ selectedSeasonID = seasons.first {
+ $0.ratingKey == selectedEpisode?.parentRatingKey
+ }?.id ?? seasons.first?.id
+ } else if forceRefresh, let selectedSeason {
+ await browserStore.loadChildren(of: selectedSeason, forceRefresh: true)
+ let refreshedEpisodes = browserStore.children(of: selectedSeason).filter {
+ $0.type?.lowercased() == "episode"
+ }
+ let episode = refreshedEpisodes.first {
+ $0.ratingKey == selectedEpisode?.ratingKey
+ } ?? refreshedEpisodes.first
+ await selectEpisode(episode)
+ }
+
+ _ = await capabilityLoad
+ }
+
+ private func loadSelectedSeason() async {
+ guard let selectedSeason else {
+ return
+ }
+ isLoadingEpisode = true
+ defer { isLoadingEpisode = false }
+
+ await browserStore.loadChildren(of: selectedSeason)
+ guard self.selectedSeason?.id == selectedSeason.id, !Task.isCancelled else {
+ return
+ }
+
+ let seasonEpisodes = browserStore.children(of: selectedSeason).filter {
+ $0.type?.lowercased() == "episode"
+ }
+ let episode = seasonEpisodes.first {
+ $0.ratingKey == selectedEpisode?.ratingKey
+ } ?? seasonEpisodes.first
+ await selectEpisode(episode)
+ }
+
+ private func selectEpisode(_ episode: PlexMediaItem?) async {
+ guard let episode else {
+ selectedEpisode = nil
+ episodeSeriesCast = []
+ return
+ }
+
+ selectedEpisode = episode
+ isLoadingEpisode = true
+ let requestedRatingKey = episode.ratingKey
+ async let detailLoad = browserStore.details(for: episode)
+ async let castLoad = browserStore.episodeSeriesCast(for: episode)
+ let (details, cast) = await (detailLoad, castLoad)
+ guard selectedEpisode?.ratingKey == requestedRatingKey, !Task.isCancelled else {
+ return
+ }
+ selectedEpisode = details
+ episodeSeriesCast = cast
+ isLoadingEpisode = false
+ }
+
+ private func refresh() {
+ guard !isReloading else { return }
+ Task { await loadShow(forceRefresh: true) }
+ }
+
+ private func preparePlayback(_ startOption: PlexPlaybackStartOption) {
+ guard let episode = selectedEpisode, !isPreparingPlayback else {
+ return
+ }
+ isPreparingPlayback = true
+ Task {
+ defer { isPreparingPlayback = false }
+ do {
+ let playbackItem = try await browserStore.refreshedPlayableDetails(for: episode)
+ guard let source = playbackItem.defaultPlaybackSource else {
+ throw PlexAPIError.noPlayableMedia
+ }
+ let videoQuality = settingsStore.videoQuality(for: activeConnectionKind)
+ async let plan = browserStore.playbackPlan(
+ for: playbackItem,
+ source: source,
+ videoQuality: videoQuality,
+ startTimeOverride: startOption.startTimeOverride
+ )
+ async let queue = browserStore.continuousPlayQueue(for: playbackItem)
+ let presentation = try await PlexPlaybackPresentation(
+ item: playbackItem,
+ plan: plan,
+ queue: queue,
+ videoQuality: videoQuality,
+ serverIdentifier: connectionStore.activeConnection?.serverID
+ ?? settingsStore.selectedServerIdentifier?.nilIfBlank
+ )
+ playerCoordinator.present(presentation)
+ } catch {
+ playbackErrorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ private func prepareDownload(_ episode: PlexMediaItem) {
+ guard let source = episode.defaultPlaybackSource else { return }
+ Task {
+ do {
+ try await downloadsStore.download(episode, source: source)
+ } catch {
+ downloadErrorMessage = error.localizedDescription
+ }
+ }
+ }
+}
diff --git a/PlexBar/Views/PlexUpNextInspector.swift b/PlexBar/Views/PlexUpNextInspector.swift
new file mode 100644
index 0000000..c08f005
--- /dev/null
+++ b/PlexBar/Views/PlexUpNextInspector.swift
@@ -0,0 +1,341 @@
+import PlexModels
+import SwiftUI
+
+struct PlexUpNextHUD: View {
+ let session: PlexPlayerSessionModel
+ @State private var pendingUpcomingItemIDs: [String]?
+
+ var body: some View {
+ queueList
+ .frame(width: 440, height: hudHeight)
+ .overlay {
+ if session.isUpdatingQueue {
+ ProgressView()
+ .controlSize(.small)
+ .padding(10)
+ .background(.regularMaterial, in: .circle)
+ .accessibilityLabel("Updating Up Next")
+ }
+ }
+ .task {
+ await session.refreshQueueWindow()
+ }
+ .onChange(of: session.queuePresentation?.upcomingItems.map(\.id)) {
+ pendingUpcomingItemIDs = nil
+ }
+ .onChange(of: session.isUpdatingQueue) { wasUpdating, isUpdating in
+ if wasUpdating, !isUpdating {
+ pendingUpcomingItemIDs = nil
+ }
+ }
+ }
+
+ private var hudHeight: CGFloat {
+ guard let queue = session.queuePresentation else {
+ return 142
+ }
+ let visibleRowCount = min(queue.upcomingItems.count, 5)
+ return min(max(170 + CGFloat(visibleRowCount) * 74, 220), 520)
+ }
+
+ private var queueList: some View {
+ List {
+ Section("Playing Now") {
+ PlexQueueItemRow(
+ item: session.queuePresentation?.currentItem ?? session.presentation.item,
+ serverURL: session.artworkServerURL,
+ token: session.artworkToken,
+ clientContext: session.artworkClientContext,
+ isNowPlaying: true,
+ play: nil,
+ queueActions: nil
+ )
+ }
+
+ if let queue = session.queuePresentation,
+ !queue.upcomingItems.isEmpty {
+ Section("Next") {
+ ForEach(displayedUpcomingItems(in: queue)) { item in
+ PlexUpcomingQueueItemRow(
+ item: item,
+ session: session
+ )
+ .moveDisabled(!session.canReorderUpcomingItems)
+ }
+ .onMove(perform: moveUpcomingItems)
+ }
+ }
+
+ if let queueErrorMessage = session.queueErrorMessage,
+ session.queuePresentation != nil {
+ Section("Queue Error") {
+ Text(queueErrorMessage)
+ .foregroundStyle(.secondary)
+
+ Button("Refresh Queue", action: session.requestQueueRefresh)
+ .disabled(session.isUpdatingQueue || session.isLoading)
+ }
+ }
+
+ if let queue = session.queuePresentation {
+ Section {
+ VStack(alignment: .leading, spacing: 3) {
+ if queue.isShuffled {
+ Label("Shuffled", systemImage: "shuffle")
+ }
+ Text("Item \(queue.currentPosition.formatted()) of \(queue.totalCount.formatted())")
+ if queue.unloadedRemainingCount > 0 {
+ Text("\(queue.unloadedRemainingCount.formatted()) additional items remain on the server.")
+ }
+ }
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .accessibilityElement(children: .combine)
+ }
+ }
+ }
+ .listStyle(.plain)
+ .scrollContentBackground(.hidden)
+ }
+
+ private func displayedUpcomingItems(
+ in queue: PlexPlaybackQueuePresentation
+ ) -> [PlexMediaItem] {
+ guard let pendingUpcomingItemIDs,
+ pendingUpcomingItemIDs.count == queue.upcomingItems.count else {
+ return queue.upcomingItems
+ }
+ let itemsByID = Dictionary(
+ uniqueKeysWithValues: queue.upcomingItems.map { ($0.id, $0) }
+ )
+ let reorderedItems = pendingUpcomingItemIDs.compactMap { itemsByID[$0] }
+ return reorderedItems.count == queue.upcomingItems.count
+ ? reorderedItems
+ : queue.upcomingItems
+ }
+
+ private func moveUpcomingItems(
+ fromOffsets sourceOffsets: IndexSet,
+ toOffset destinationOffset: Int
+ ) {
+ guard let queue = session.queuePresentation,
+ session.moveUpcomingItems(
+ fromOffsets: sourceOffsets,
+ toOffset: destinationOffset
+ ) else {
+ return
+ }
+ var reorderedItems = displayedUpcomingItems(in: queue)
+ reorderedItems.move(
+ fromOffsets: sourceOffsets,
+ toOffset: destinationOffset
+ )
+ pendingUpcomingItemIDs = reorderedItems.map(\.id)
+ }
+}
+
+private struct PlexUpcomingQueueItemRow: View {
+ let item: PlexMediaItem
+ let session: PlexPlayerSessionModel
+
+ var body: some View {
+ PlexQueueItemRow(
+ item: item,
+ serverURL: session.artworkServerURL,
+ token: session.artworkToken,
+ clientContext: session.artworkClientContext,
+ isNowPlaying: false,
+ play: play,
+ queueActions: queueActions
+ )
+ .disabled(session.isLoading || session.isUpdatingQueue)
+ }
+
+ private var play: (() -> Void)? {
+ guard let playQueueItemID = item.playQueueItemID?.nilIfBlank else {
+ return nil
+ }
+ return {
+ session.playUpcomingItem(playQueueItemID: playQueueItemID)
+ }
+ }
+
+ private var queueActions: PlexQueueItemActions? {
+ guard let playQueueItemID = item.playQueueItemID?.nilIfBlank else {
+ return nil
+ }
+ return PlexQueueItemActions(
+ play: {
+ session.playUpcomingItem(playQueueItemID: playQueueItemID)
+ },
+ moveUp: {
+ session.moveUpcomingItem(
+ playQueueItemID: playQueueItemID,
+ direction: .up
+ )
+ },
+ moveDown: {
+ session.moveUpcomingItem(
+ playQueueItemID: playQueueItemID,
+ direction: .down
+ )
+ },
+ remove: {
+ session.removeUpcomingItem(playQueueItemID: playQueueItemID)
+ },
+ canMoveUp: session.canMoveUpcomingItem(
+ playQueueItemID: playQueueItemID,
+ direction: .up
+ ),
+ canMoveDown: session.canMoveUpcomingItem(
+ playQueueItemID: playQueueItemID,
+ direction: .down
+ ),
+ canRemove: session.canRemoveUpcomingItem(
+ playQueueItemID: playQueueItemID
+ )
+ )
+ }
+}
+
+private struct PlexQueueItemActions {
+ let play: () -> Void
+ let moveUp: () -> Void
+ let moveDown: () -> Void
+ let remove: () -> Void
+ let canMoveUp: Bool
+ let canMoveDown: Bool
+ let canRemove: Bool
+}
+
+private struct PlexQueueItemActionMenuItems: View {
+ let actions: PlexQueueItemActions
+
+ var body: some View {
+ Button("Play Now", systemImage: "play.fill", action: actions.play)
+
+ Divider()
+
+ Button("Move Up", systemImage: "arrow.up", action: actions.moveUp)
+ .disabled(!actions.canMoveUp)
+
+ Button("Move Down", systemImage: "arrow.down", action: actions.moveDown)
+ .disabled(!actions.canMoveDown)
+
+ Divider()
+
+ Button(
+ "Remove from Up Next",
+ systemImage: "minus.circle",
+ role: .destructive,
+ action: actions.remove
+ )
+ .disabled(!actions.canRemove)
+ }
+}
+
+private struct PlexQueueItemRow: View {
+ let item: PlexMediaItem
+ let serverURL: URL?
+ let token: String
+ let clientContext: PlexClientContext
+ let isNowPlaying: Bool
+ let play: (() -> Void)?
+ let queueActions: PlexQueueItemActions?
+ @ScaledMetric(relativeTo: .body) private var audioArtworkSize: CGFloat = 48
+ @ScaledMetric(relativeTo: .body) private var videoArtworkWidth: CGFloat = 42
+ @ScaledMetric(relativeTo: .body) private var videoArtworkHeight: CGFloat = 62
+
+ var body: some View {
+ HStack(spacing: 4) {
+ Group {
+ if let play {
+ Button(action: play) {
+ rowContent
+ }
+ .buttonStyle(.plain)
+ .accessibilityHint("Plays this item from the queue.")
+ } else {
+ rowContent
+ }
+ }
+
+ if let queueActions {
+ Menu("Queue Actions", systemImage: "ellipsis.circle") {
+ PlexQueueItemActionMenuItems(actions: queueActions)
+ }
+ .labelStyle(.iconOnly)
+ .menuStyle(.borderlessButton)
+ .controlSize(.small)
+ .fixedSize()
+ .help("Queue Actions")
+ }
+ }
+ .contextMenu {
+ if let queueActions {
+ PlexQueueItemActionMenuItems(actions: queueActions)
+ }
+ }
+ .listRowInsets(EdgeInsets(top: 6, leading: 8, bottom: 6, trailing: 8))
+ }
+
+ private var rowContent: some View {
+ HStack(spacing: 10) {
+ PlexArtworkView(
+ primaryImageURL: artworkURL,
+ fallbackImageURL: nil,
+ token: token,
+ clientContext: clientContext,
+ placeholderSymbol: item.placeholderSymbol,
+ width: artworkWidth,
+ height: artworkHeight,
+ cornerRadius: 6
+ )
+ .accessibilityHidden(true)
+
+ VStack(alignment: .leading, spacing: 3) {
+ Text(item.title)
+ .font(.body.weight(isNowPlaying ? .semibold : .regular))
+ .lineLimit(2)
+
+ if let subtitle = item.subtitle {
+ Text(subtitle)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ if isNowPlaying {
+ Image(systemName: "speaker.wave.2.fill")
+ .foregroundStyle(.tint)
+ .accessibilityHidden(true)
+ }
+ }
+ .contentShape(.rect)
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel(accessibilityLabel)
+ }
+
+ private var artworkURL: URL? {
+ guard let serverURL else {
+ return nil
+ }
+ return PlexURLBuilder.mediaURL(serverURL: serverURL, path: item.posterArtworkPath)
+ }
+
+ private var artworkWidth: CGFloat {
+ item.continuousPlayQueueType == .audio ? audioArtworkSize : videoArtworkWidth
+ }
+
+ private var artworkHeight: CGFloat {
+ item.continuousPlayQueueType == .audio ? audioArtworkSize : videoArtworkHeight
+ }
+
+ private var accessibilityLabel: String {
+ [isNowPlaying ? "Now Playing" : nil, item.title, item.subtitle]
+ .compactMap { $0?.nilIfBlank }
+ .joined(separator: ", ")
+ }
+}
diff --git a/PlexBar/Views/PlexWatchedActions.swift b/PlexBar/Views/PlexWatchedActions.swift
new file mode 100644
index 0000000..38c9b8f
--- /dev/null
+++ b/PlexBar/Views/PlexWatchedActions.swift
@@ -0,0 +1,576 @@
+import PlexModels
+import SwiftUI
+
+struct PlexWatchedStateButton: View {
+ @Binding var item: PlexMediaItem
+ @Bindable var browserStore: PlexBrowserStore
+ @State private var errorMessage: String?
+
+ var body: some View {
+ if browserStore.supportsWatchedStateMutation(for: item) {
+ Button(action: updateWatchedState) {
+ if browserStore.isUpdatingWatchedState(for: item) {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Label(item.watchedActionTitle, systemImage: item.watchedActionSystemImage)
+ }
+ }
+ .disabled(browserStore.isUpdatingWatchedState(for: item))
+ .accessibilityLabel(item.watchedActionTitle)
+ .plexMutationErrorAlert(
+ title: "Couldn’t Update Watched Status",
+ message: $errorMessage
+ )
+ }
+ }
+
+ private func updateWatchedState() {
+ let watched = !item.isWatched
+ Task {
+ do {
+ item = try await browserStore.setWatched(watched, for: item)
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+}
+
+struct PlexPersonalRatingMenu: View {
+ @Binding var item: PlexMediaItem
+ @Bindable var browserStore: PlexBrowserStore
+ @State private var errorMessage: String?
+ @State private var isRatingPopoverPresented = false
+
+ var body: some View {
+ if browserStore.supportsPersonalRatings {
+ Button {
+ isRatingPopoverPresented.toggle()
+ } label: {
+ if browserStore.isUpdatingPersonalRating(for: item) {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Label(item.personalRatingTitle, systemImage: item.personalRatingSystemImage)
+ }
+ }
+ .disabled(browserStore.isUpdatingPersonalRating(for: item))
+ .accessibilityLabel(item.personalRatingAccessibilityLabel)
+ .popover(isPresented: $isRatingPopoverPresented, arrowEdge: .bottom) {
+ ratingPopover
+ }
+ .plexMutationErrorAlert(
+ title: "Couldn’t Update Rating",
+ message: $errorMessage
+ )
+ }
+ }
+
+ private var ratingPopover: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("Your Rating")
+ .font(.headline)
+
+ PlexStarRatingPicker(
+ serverValue: item.userRating,
+ isDisabled: browserStore.isUpdatingPersonalRating(for: item),
+ onSelect: updateRating
+ )
+
+ Divider()
+
+ Button("Clear Rating", systemImage: "xmark.circle", action: clearRating)
+ .disabled(item.userRating == nil)
+ }
+ .padding()
+ }
+
+ private func clearRating() {
+ updateRating(nil)
+ }
+
+ private func updateRating(_ rating: Double?) {
+ isRatingPopoverPresented = false
+ Task {
+ do {
+ item = try await browserStore.setPersonalRating(rating, for: item)
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+}
+
+struct PlexMetadataRefreshButton: View {
+ let item: PlexMediaItem
+ @Bindable var browserStore: PlexBrowserStore
+ @State private var errorMessage: String?
+
+ var body: some View {
+ if browserStore.supportsMetadataRefresh(for: item) {
+ Button(action: refreshMetadata) {
+ if browserStore.isRefreshingMetadata(for: item) {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Label("Refresh Metadata", systemImage: "arrow.trianglehead.2.clockwise.rotate.90")
+ }
+ }
+ .disabled(browserStore.isRefreshingMetadata(for: item))
+ .accessibilityLabel("Refresh metadata for \(item.title)")
+ .plexMutationErrorAlert(
+ title: "Couldn’t Refresh Metadata",
+ message: $errorMessage
+ )
+ }
+ }
+
+ private func refreshMetadata() {
+ Task {
+ do {
+ try await browserStore.refreshMetadata(for: item)
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+}
+
+struct PlexPlaybackQueueMenu: View {
+ let item: PlexMediaItem
+ @Bindable var playerCoordinator: PlexPlayerCoordinator
+ @State private var errorMessage: String?
+
+ var body: some View {
+ if playerCoordinator.canAddToQueue(item) {
+ Menu {
+ Button("Play Next", systemImage: "text.insert", action: playNext)
+ Button("Add to Up Next", systemImage: "text.badge.plus", action: addToUpNext)
+ } label: {
+ if playerCoordinator.isAddingToQueue {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Label("Queue", systemImage: "text.badge.plus")
+ }
+ }
+ .disabled(playerCoordinator.isAddingToQueue)
+ .accessibilityLabel("Queue \(item.title)")
+ .plexMutationErrorAlert(
+ title: "Couldn’t Update Up Next",
+ message: $errorMessage
+ )
+ }
+ }
+
+ private func playNext() {
+ add(insertion: .next)
+ }
+
+ private func addToUpNext() {
+ add(insertion: .upNext)
+ }
+
+ private func add(insertion: PlexPlayQueueInsertion) {
+ Task {
+ do {
+ try await playerCoordinator.addToQueue(item, insertion: insertion)
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+}
+
+private struct PlexMediaContextMenuModifier: ViewModifier {
+ @Environment(PlexDownloadsStore.self) private var downloadsStore
+ let item: PlexMediaItem
+ let parent: PlexMediaItem?
+ let library: PlexLibrary?
+ let allowsRemovalFromContinueWatching: Bool
+ @Bindable var browserStore: PlexBrowserStore
+ @Bindable var playerCoordinator: PlexPlayerCoordinator
+ @State private var errorMessage: String?
+ @State private var isConfirmingRemoval = false
+ @State private var isRatingPopoverPresented = false
+ @State private var presentedOrganizationRequest: PlexMediaOrganizationRequest?
+
+ func body(content: Content) -> some View {
+ content
+ .contextMenu {
+ PlexMediaHierarchyNavigationMenu(destinations: item.hierarchyDestinations)
+
+ if !item.hierarchyDestinations.isEmpty, hasActionsAfterHierarchyNavigation {
+ Divider()
+ }
+
+ if showsDownloadAction {
+ if downloadsStore.containsDownload(for: item) {
+ Button("Downloaded", systemImage: "checkmark.circle.fill") {}
+ .disabled(true)
+ } else if downloadsStore.isDownloading(item) {
+ Button("Downloading", systemImage: "arrow.down.circle") {}
+ .disabled(true)
+ } else {
+ Button("Download", systemImage: "arrow.down.circle", action: download)
+ }
+ }
+
+ if browserStore.supportsWatchedStateMutation(for: item) {
+ Button(action: updateWatchedState) {
+ Label(item.watchedActionTitle, systemImage: item.watchedActionSystemImage)
+ }
+ .disabled(browserStore.isUpdatingWatchedState(for: item))
+ }
+
+ if browserStore.supportsPersonalRatings {
+ Button {
+ isRatingPopoverPresented = true
+ } label: {
+ Label(item.personalRatingTitle, systemImage: item.personalRatingSystemImage)
+ }
+ .disabled(browserStore.isUpdatingPersonalRating(for: item))
+ }
+
+ if playerCoordinator.canAddToQueue(item) {
+ Divider()
+
+ Button("Play Next", systemImage: "text.insert", action: playNext)
+ .disabled(playerCoordinator.isAddingToQueue)
+ Button("Add to Up Next", systemImage: "text.badge.plus", action: addToUpNext)
+ .disabled(playerCoordinator.isAddingToQueue)
+ }
+
+ if allowsRemovalFromContinueWatching,
+ browserStore.supportsRemoveFromContinueWatching {
+ Button(
+ "Remove from Continue Watching",
+ systemImage: "rectangle.badge.xmark",
+ action: removeFromContinueWatching
+ )
+ .disabled(browserStore.isRemovingFromContinueWatching(item))
+ }
+
+ if browserStore.supportsMetadataRefresh(for: item) {
+ Divider()
+
+ Button(action: refreshMetadata) {
+ Label("Refresh Metadata", systemImage: "arrow.trianglehead.2.clockwise.rotate.90")
+ }
+ .disabled(browserStore.isRefreshingMetadata(for: item))
+ }
+
+ if let parent, browserStore.supportsChildManagement(of: parent) {
+ Divider()
+
+ Button("Move Up", systemImage: "arrow.up", action: moveUp)
+ .disabled(
+ browserStore.isManagingCollectionOrPlaylist(parent)
+ || !browserStore.canMoveChild(item, in: parent, direction: .up)
+ )
+ Button("Move Down", systemImage: "arrow.down", action: moveDown)
+ .disabled(
+ browserStore.isManagingCollectionOrPlaylist(parent)
+ || !browserStore.canMoveChild(item, in: parent, direction: .down)
+ )
+
+ Divider()
+
+ Button(removalTitle(for: parent), systemImage: "minus.circle", role: .destructive) {
+ isConfirmingRemoval = true
+ }
+ .disabled(browserStore.isManagingCollectionOrPlaylist(parent))
+ }
+
+ if library != nil,
+ item.playlistMediaType != nil,
+ browserStore.supportsCollectionManagement || browserStore.supportsPlaylistCreation {
+ Divider()
+
+ if browserStore.supportsCollectionManagement {
+ Button("Add to Collection…", systemImage: "rectangle.stack.badge.plus") {
+ presentedOrganizationRequest = .collection
+ }
+ }
+ if browserStore.supportsPlaylistCreation {
+ Button("Add to Playlist…", systemImage: "text.badge.plus") {
+ presentedOrganizationRequest = .playlist
+ }
+ }
+ }
+ }
+ .plexMutationErrorAlert(
+ title: "Couldn’t Update Item",
+ message: $errorMessage
+ )
+ .popover(isPresented: $isRatingPopoverPresented, arrowEdge: .bottom) {
+ ratingPopover
+ }
+ .confirmationDialog(
+ removalConfirmationTitle,
+ isPresented: $isConfirmingRemoval
+ ) {
+ Button(removalConfirmationButtonTitle, role: .destructive, action: removeFromParent)
+ } message: {
+ Text(removalConfirmationMessage)
+ }
+ .sheet(item: $presentedOrganizationRequest) { request in
+ switch request {
+ case .collection:
+ if let library {
+ PlexAddToCollectionSheet(
+ item: item,
+ library: library,
+ browserStore: browserStore
+ )
+ }
+ case .playlist:
+ PlexAddToPlaylistSheet(item: item, browserStore: browserStore)
+ }
+ }
+ }
+
+ private var hasActionsAfterHierarchyNavigation: Bool {
+ showsDownloadAction
+ || browserStore.supportsWatchedStateMutation(for: item)
+ || browserStore.supportsPersonalRatings
+ || playerCoordinator.canAddToQueue(item)
+ || (allowsRemovalFromContinueWatching && browserStore.supportsRemoveFromContinueWatching)
+ || browserStore.supportsMetadataRefresh(for: item)
+ || (parent.map(browserStore.supportsChildManagement(of:)) ?? false)
+ || (
+ library != nil
+ && item.playlistMediaType != nil
+ && (browserStore.supportsCollectionManagement || browserStore.supportsPlaylistCreation)
+ )
+ }
+
+ private var showsDownloadAction: Bool {
+ downloadsStore.containsDownload(for: item)
+ || downloadsStore.isDownloading(item)
+ || downloadsStore.canCreateDownload(for: item, libraryID: library?.id)
+ }
+
+ private var ratingPopover: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("Your Rating")
+ .font(.headline)
+
+ PlexStarRatingPicker(
+ serverValue: item.userRating,
+ isDisabled: browserStore.isUpdatingPersonalRating(for: item),
+ onSelect: updateRating
+ )
+
+ Divider()
+
+ Button("Clear Rating", systemImage: "xmark.circle", action: clearRating)
+ .disabled(item.userRating == nil)
+ }
+ .padding()
+ }
+
+ private func updateWatchedState() {
+ let watched = !item.isWatched
+ Task {
+ do {
+ try await browserStore.setWatched(watched, for: item)
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ private func download() {
+ Task {
+ do {
+ try await downloadsStore.download(item)
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ private func clearRating() {
+ updateRating(nil)
+ }
+
+ private func updateRating(_ rating: Double?) {
+ isRatingPopoverPresented = false
+ Task {
+ do {
+ try await browserStore.setPersonalRating(rating, for: item)
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ private func refreshMetadata() {
+ Task {
+ do {
+ try await browserStore.refreshMetadata(for: item)
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ private func removeFromContinueWatching() {
+ Task {
+ do {
+ try await browserStore.removeFromContinueWatching(item)
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ private func playNext() {
+ addToQueue(insertion: .next)
+ }
+
+ private func addToUpNext() {
+ addToQueue(insertion: .upNext)
+ }
+
+ private func addToQueue(insertion: PlexPlayQueueInsertion) {
+ Task {
+ do {
+ try await playerCoordinator.addToQueue(item, insertion: insertion)
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ private func moveUp() {
+ move(.up)
+ }
+
+ private func moveDown() {
+ move(.down)
+ }
+
+ private func move(_ direction: PlexListMoveDirection) {
+ guard let parent else {
+ return
+ }
+ Task {
+ do {
+ try await browserStore.moveChild(item, in: parent, direction: direction)
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ private func removeFromParent() {
+ guard let parent else {
+ return
+ }
+ Task {
+ do {
+ try await browserStore.removeChild(item, from: parent)
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ private func removalTitle(for parent: PlexMediaItem) -> String {
+ parent.type?.lowercased() == "collection"
+ ? "Remove from Collection"
+ : "Remove from Playlist"
+ }
+
+ private var removalConfirmationTitle: String {
+ guard let parent else {
+ return "Remove Item?"
+ }
+ return parent.type?.lowercased() == "collection"
+ ? "Remove from Collection?"
+ : "Remove from Playlist?"
+ }
+
+ private var removalConfirmationButtonTitle: String {
+ guard let parent else {
+ return "Remove"
+ }
+ return removalTitle(for: parent)
+ }
+
+ private var removalConfirmationMessage: String {
+ guard let parent else {
+ return ""
+ }
+ return "This removes \(item.title) from \(parent.title). The media file is not deleted."
+ }
+}
+
+extension View {
+ func plexMediaContextMenu(
+ for item: PlexMediaItem,
+ in parent: PlexMediaItem? = nil,
+ library: PlexLibrary? = nil,
+ allowsRemovalFromContinueWatching: Bool = false,
+ browserStore: PlexBrowserStore,
+ playerCoordinator: PlexPlayerCoordinator
+ ) -> some View {
+ modifier(PlexMediaContextMenuModifier(
+ item: item,
+ parent: parent,
+ library: library,
+ allowsRemovalFromContinueWatching: allowsRemovalFromContinueWatching,
+ browserStore: browserStore,
+ playerCoordinator: playerCoordinator
+ ))
+ }
+
+ func plexMutationErrorAlert(
+ title: String,
+ message: Binding
+ ) -> some View {
+ alert(
+ title,
+ isPresented: Binding(
+ get: { message.wrappedValue != nil },
+ set: { isPresented in
+ if !isPresented {
+ message.wrappedValue = nil
+ }
+ }
+ )
+ ) {
+ Button("OK") {}
+ } message: {
+ Text(message.wrappedValue ?? "Unknown Plex error.")
+ }
+ }
+}
+
+extension PlexMediaItem {
+ var watchedActionTitle: String {
+ isWatched ? "Mark as Unwatched" : "Mark as Watched"
+ }
+
+ var watchedActionSystemImage: String {
+ isWatched ? "eye.slash" : "eye"
+ }
+
+ var personalRatingTitle: String {
+ PlexPersonalRating.title(forServerValue: userRating)
+ }
+
+ var personalRatingAccessibilityLabel: String {
+ guard PlexPersonalRating.stars(fromServerValue: userRating) != nil else {
+ return "Rate \(title)"
+ }
+ return "Your rating for \(title) is \(PlexPersonalRating.accessibilityValue(forServerValue: userRating))"
+ }
+
+ var personalRatingSystemImage: String {
+ userRating == nil ? "star" : "star.fill"
+ }
+}
diff --git a/Sources/PlexBar/Views/PosterStackView.swift b/PlexBar/Views/PosterStackView.swift
similarity index 100%
rename from Sources/PlexBar/Views/PosterStackView.swift
rename to PlexBar/Views/PosterStackView.swift
diff --git a/Sources/PlexBar/Views/SettingsAboutView.swift b/PlexBar/Views/SettingsAboutView.swift
similarity index 96%
rename from Sources/PlexBar/Views/SettingsAboutView.swift
rename to PlexBar/Views/SettingsAboutView.swift
index 3cdd8a4..fe1927c 100644
--- a/Sources/PlexBar/Views/SettingsAboutView.swift
+++ b/PlexBar/Views/SettingsAboutView.swift
@@ -30,14 +30,14 @@ struct SettingsAboutView: View {
.font(.system(size: 22, weight: .bold, design: .monospaced))
.foregroundStyle(.primary)
- Text("Telemetry for Plex")
+ Text("Native Plex Client")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.secondary)
}
}
VStack(spacing: 16) {
- Text("A lightweight macOS menu bar app for Plex server telemetry.")
+ Text("Browse and play your Plex library in a fully native macOS app.")
.font(.system(size: 11))
.foregroundStyle(.primary)
.multilineTextAlignment(.center)
diff --git a/Sources/PlexBar/Views/SettingsView.swift b/PlexBar/Views/SettingsView.swift
similarity index 74%
rename from Sources/PlexBar/Views/SettingsView.swift
rename to PlexBar/Views/SettingsView.swift
index 5b8446b..93a0dff 100644
--- a/Sources/PlexBar/Views/SettingsView.swift
+++ b/PlexBar/Views/SettingsView.swift
@@ -1,3 +1,4 @@
+import PlexModels
import SwiftUI
struct SettingsView: View {
@@ -19,6 +20,10 @@ struct SettingsView: View {
generalView
}
+ Tab("Downloads", systemImage: "arrow.down.circle", value: .downloads) {
+ PlexDownloadSettingsView(settingsStore: settingsStore)
+ }
+
Tab("About", systemImage: "info.circle", value: .about) {
aboutView
}
@@ -41,6 +46,8 @@ struct SettingsView: View {
}
}
.task {
+ await settingsStore.loadCredentials()
+ await authStore.credentialsDidLoad()
settingsStore.refreshOpenAtLoginStatus()
}
.onChange(of: isShowingServerList) { _, isShowingServerList in
@@ -65,6 +72,7 @@ struct SettingsView: View {
private enum SettingsTab: Hashable {
case general
+ case downloads
case about
}
@@ -75,7 +83,26 @@ struct SettingsView: View {
private var generalView: some View {
Group {
- if settingsStore.hasAuthenticatedAccount {
+ if !settingsStore.hasLoadedCredentials {
+ if let errorMessage = settingsStore.credentialLoadingErrorMessage,
+ !settingsStore.isLoadingCredentials {
+ ContentUnavailableView {
+ Label("Couldn’t Access Keychain", systemImage: "key.slash")
+ } description: {
+ Text(errorMessage)
+ } actions: {
+ Button("Try Again") {
+ Task {
+ await settingsStore.loadCredentials()
+ await authStore.credentialsDidLoad()
+ }
+ }
+ }
+ } else {
+ ProgressView("Loading Account…")
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ } else if settingsStore.hasAuthenticatedAccount {
authenticatedView
} else {
unauthenticatedView
@@ -95,6 +122,12 @@ struct SettingsView: View {
private var authenticatedView: some View {
VStack(spacing: 0) {
Form {
+ if let credentialErrorMessage = settingsStore.credentialPersistenceErrorMessage {
+ Section {
+ serverStatusBanner(message: credentialErrorMessage)
+ }
+ }
+
Section {
VStack(alignment: .leading, spacing: 10) {
Text("Account")
@@ -151,6 +184,134 @@ struct SettingsView: View {
kind: .historyRefresh
)
}
+
+ LabeledContent("Local Video Quality") {
+ videoQualityPicker(selection: $settingsStore.localVideoQuality)
+ }
+
+ Toggle(
+ "Quality Suggestions",
+ isOn: $settingsStore.qualitySuggestionsEnabled
+ )
+
+ LabeledContent(
+ settingsStore.qualitySuggestionsEnabled
+ ? "Maximum Remote Quality"
+ : "Remote Video Quality"
+ ) {
+ videoQualityPicker(selection: $settingsStore.remoteVideoQuality)
+ }
+
+ Toggle("Allow Direct Play", isOn: $settingsStore.allowsDirectPlay)
+
+ Toggle("Allow Direct Stream", isOn: $settingsStore.allowsDirectStream)
+
+ Toggle("Force Direct Play", isOn: $settingsStore.forceDirectPlay)
+ .disabled(!settingsStore.allowsDirectPlay)
+ .help("Try supported media directly before asking Plex to convert it.")
+
+ LabeledContent("Video Dynamic Range") {
+ Picker("Video Dynamic Range", selection: $settingsStore.videoDynamicRange) {
+ ForEach(PlexVideoDisplayDynamicRange.allCases) { dynamicRange in
+ Text(dynamicRange.label)
+ .tag(dynamicRange)
+ }
+ }
+ .labelsHidden()
+ }
+
+ LabeledContent("Video Scaling") {
+ Picker("Video Scaling", selection: $settingsStore.videoScalingMode) {
+ ForEach(PlexVideoScalingMode.allCases) { scalingMode in
+ Text(scalingMode.label)
+ .tag(scalingMode)
+ }
+ }
+ .labelsHidden()
+ }
+
+ LabeledContent("Hide Episode Spoilers") {
+ Picker("Hide Episode Spoilers", selection: $settingsStore.episodeSpoilerPolicy) {
+ ForEach(PlexEpisodeSpoilerPolicy.allCases) { policy in
+ Text(policy.label)
+ .tag(policy)
+ }
+ }
+ .labelsHidden()
+ }
+
+ LabeledContent("Cinema Trailers") {
+ Picker(
+ "Cinema Trailers",
+ selection: $settingsStore.cinemaPreplayPreference
+ ) {
+ ForEach(PlexCinemaPreplayPreference.allCases) { preference in
+ Text(preference.label)
+ .tag(preference)
+ }
+ }
+ .labelsHidden()
+ }
+
+ Toggle("Auto Play Up Next", isOn: $settingsStore.autoplayUpNext)
+
+ LabeledContent("Auto Play Countdown") {
+ Picker("Auto Play Countdown", selection: $settingsStore.autoplayCountdown) {
+ ForEach(PlexAutoplayCountdown.allCases) { countdown in
+ Text(countdown.label)
+ .tag(countdown)
+ }
+ }
+ .labelsHidden()
+ }
+ .disabled(!settingsStore.autoplayUpNext)
+
+ LabeledContent("Passout Protection") {
+ Picker("Passout Protection", selection: $settingsStore.passoutProtection) {
+ ForEach(PlexPassoutProtection.allCases) { protection in
+ Text(protection.label)
+ .tag(protection)
+ }
+ }
+ .labelsHidden()
+ }
+ .disabled(!settingsStore.autoplayUpNext)
+
+ LabeledContent("Rewind on Resume") {
+ Stepper(
+ value: Binding(
+ get: { settingsStore.rewindOnResume.seconds },
+ set: { settingsStore.rewindOnResume = PlexRewindOnResume(seconds: $0) }
+ ),
+ in: PlexRewindOnResume.secondsRange
+ ) {
+ Text(settingsStore.rewindOnResume.label)
+ .monospacedDigit()
+ }
+ .accessibilityLabel("Rewind on Resume")
+ .accessibilityValue(settingsStore.rewindOnResume.label)
+ }
+
+ LabeledContent("Skip Intro") {
+ playbackMarkerBehaviorPicker(
+ "Skip Intro",
+ selection: $settingsStore.skipIntroBehavior
+ )
+ }
+
+ LabeledContent("Skip Ads in Recorded Content") {
+ playbackMarkerBehaviorPicker(
+ "Skip Ads in Recorded Content",
+ selection: $settingsStore.skipAdsBehavior
+ )
+ }
+
+ LabeledContent("Skip Credits") {
+ playbackMarkerBehaviorPicker(
+ "Skip Credits",
+ selection: $settingsStore.skipCreditsBehavior
+ )
+ }
}
Section {
@@ -183,19 +344,29 @@ struct SettingsView: View {
.multilineTextAlignment(.center)
}
- Button("Sign In With Plex") {
- authStore.startSignIn()
+ if authStore.canCancelSignIn {
+ Button("Cancel", role: .cancel) {
+ authStore.cancelSignIn()
+ }
+ } else if !authStore.isAuthenticating {
+ Button("Sign In With Plex") {
+ authStore.startSignIn()
+ }
+ .buttonStyle(.borderedProminent)
}
- .buttonStyle(.borderedProminent)
- .disabled(authStore.isAuthenticating)
- if let statusMessage = authStore.statusMessage {
- Text(statusLine(statusMessage))
- .font(.footnote)
- .foregroundStyle(.secondary)
+ if let progressMessage = authStore.signInProgressMessage {
+ HStack(spacing: 8) {
+ ProgressView()
+ .controlSize(.small)
+ Text(progressMessage)
+ }
+ .font(.footnote)
+ .foregroundStyle(.secondary)
}
- if let errorMessage = authStore.errorMessage {
+ if let errorMessage = authStore.errorMessage
+ ?? settingsStore.credentialPersistenceErrorMessage {
Text(errorMessage)
.font(.footnote)
.foregroundStyle(.red)
@@ -246,6 +417,29 @@ struct SettingsView: View {
)
}
+ private func videoQualityPicker(selection: Binding) -> some View {
+ Picker("Video Quality", selection: selection) {
+ ForEach(PlexVideoQuality.allCases) { quality in
+ Text(quality.label)
+ .tag(quality)
+ }
+ }
+ .labelsHidden()
+ }
+
+ private func playbackMarkerBehaviorPicker(
+ _ title: String,
+ selection: Binding
+ ) -> some View {
+ Picker(title, selection: selection) {
+ ForEach(PlexPlaybackMarkerBehavior.allCases) { behavior in
+ Text(behavior.label)
+ .tag(behavior)
+ }
+ }
+ .labelsHidden()
+ }
+
private var openAtLoginBinding: Binding {
Binding(
get: { settingsStore.opensAtLogin },
@@ -259,16 +453,6 @@ struct SettingsView: View {
)
}
- private func statusLine(_ message: String) -> String {
- if let remainingSeconds = authStore.remainingSeconds {
- let minutes = remainingSeconds / 60
- let seconds = remainingSeconds % 60
- return "\(message) \(minutes):" + String(format: "%02d", seconds)
- }
-
- return message
- }
-
private var selectedServer: PlexServerResource? {
guard let selectedServerIdentifier = settingsStore.selectedServerIdentifier else {
return nil
diff --git a/Sources/PlexBar/Views/StreamCardView.swift b/PlexBar/Views/StreamCardView.swift
similarity index 87%
rename from Sources/PlexBar/Views/StreamCardView.swift
rename to PlexBar/Views/StreamCardView.swift
index e69e334..cc28dbe 100644
--- a/Sources/PlexBar/Views/StreamCardView.swift
+++ b/PlexBar/Views/StreamCardView.swift
@@ -1,3 +1,4 @@
+import PlexModels
import SwiftUI
private struct StreamCardTheme {
@@ -60,6 +61,9 @@ private enum StreamArtworkMetrics {
struct StreamCardView: View {
@Environment(\.colorScheme) private var colorScheme
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+ @FocusState private var isCardFocused: Bool
+ @State private var isCardHovered = false
let session: PlexSession
let sessionStore: PlexSessionStore
let onRequestTerminate: (PlexSession) -> Void
@@ -73,6 +77,7 @@ struct StreamCardView: View {
let resolvedLocation: String?
@State private var artwork: PlexArtworkPresentationState
@State private var isShowingActionsPopover = false
+ @State private var isShowingPlaybackDetails = false
init(
session: PlexSession,
@@ -120,9 +125,47 @@ struct StreamCardView: View {
var body: some View {
let theme = StreamCardTheme.make(for: colorScheme, hasPalette: artwork.palette != nil)
let isTerminating = sessionStore.isTerminating(session)
- defaultCardContent(isTerminating: isTerminating)
+ let details = PlexSessionPlaybackDetails(session: session)
+ VStack(alignment: .leading, spacing: 0) {
+ Button(action: togglePlaybackDetails) {
+ defaultCardContent(details: details)
+ .padding(12)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(Rectangle())
+ .background {
+ RoundedRectangle(cornerRadius: 16)
+ .fill(.primary.opacity(isCardHovered ? 0.05 : 0))
+ }
+ }
+ .buttonStyle(.plain)
+ .focused($isCardFocused)
+ .overlay {
+ RoundedRectangle(cornerRadius: 16)
+ .strokeBorder(Color.accentColor, lineWidth: 2)
+ .opacity(isCardFocused ? 1 : 0)
+ .allowsHitTesting(false)
+ }
+ .onHover { isCardHovered = $0 }
+ .accessibilityElement(children: .ignore)
+ .accessibilityAddTraits(.isButton)
+ .accessibilityAction { togglePlaybackDetails() }
+ .accessibilityLabel("\(session.headline), \(session.userDisplayName)")
+ .accessibilityValue([details.method + (details.usesHardware ? ", hardware accelerated" : ""), details.bandwidth, isShowingPlaybackDetails ? "Expanded" : "Collapsed"].compactMap { $0 }.joined(separator: ", "))
+ .accessibilityHint(isShowingPlaybackDetails ? "Hide playback details" : "Show playback details")
+ .disabled(isTerminating || isShowingTerminatePrompt)
+ .overlay(alignment: .topTrailing) {
+ actionsButton(isTerminating: isTerminating)
+ .padding(12)
+ }
+
+ StreamDetailsReveal(isExpanded: isShowingPlaybackDetails) {
+ Divider().padding(.horizontal, 12)
+ StreamPlaybackDetailsView(details: details)
+ .padding(12)
+ .textSelection(.enabled)
+ }
+ }
.opacity(isShowingTerminatePrompt ? 0.38 : 1)
- .padding(12)
.frame(maxWidth: .infinity, minHeight: 132, alignment: .topLeading)
.background {
StreamCardBackground(palette: artwork.palette, theme: theme)
@@ -163,6 +206,12 @@ struct StreamCardView: View {
}
}
+ private func togglePlaybackDetails() {
+ withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.18)) {
+ isShowingPlaybackDetails.toggle()
+ }
+ }
+
private var playbackTimingSummary: String? {
guard let snapshotDate else {
return nil
@@ -204,7 +253,7 @@ struct StreamCardView: View {
}
@ViewBuilder
- private func defaultCardContent(isTerminating: Bool) -> some View {
+ private func defaultCardContent(details: PlexSessionPlaybackDetails) -> some View {
HStack(alignment: .top, spacing: 12) {
StreamArtworkView(
artwork: artwork,
@@ -220,21 +269,12 @@ struct StreamCardView: View {
.lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.trailing, 36)
- .overlay(alignment: .trailing) {
- actionsButton(isTerminating: isTerminating)
- }
- if session.contentKind == .tv || session.contentKind == .liveTV,
- let metaLine = session.contentMetaLine,
- let subtitle = session.contentSubtitle {
- HStack(spacing: 8) {
- HStack(spacing: 6) {
- Image(systemName: session.contentKind.contentMetaSymbolName)
- .foregroundStyle(.tertiary)
- Text(metaLine)
- .foregroundStyle(.secondary)
- }
- Text(subtitle)
+ if session.contentKind == .tv || session.contentKind == .liveTV {
+ HStack(spacing: 6) {
+ Image(systemName: session.contentKind.contentMetaSymbolName)
+ .foregroundStyle(.tertiary)
+ Text(session.detailLine)
.foregroundStyle(.secondary)
.lineLimit(1)
}
@@ -286,6 +326,7 @@ struct StreamCardView: View {
UserIdentityRow(
userName: session.userDisplayName,
playerName: session.playerDisplayName,
+ playbackDetails: details,
resolvedLocation: resolvedLocation,
thumb: session.user?.thumb,
serverURL: serverURL,
@@ -310,6 +351,7 @@ struct StreamCardView: View {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
+ .accessibilityLabel("Playback actions for \(session.headline)")
.disabled(isTerminating || isShowingTerminatePrompt)
.popover(isPresented: $isShowingActionsPopover, arrowEdge: .top) {
SessionActionsPopover {
@@ -570,6 +612,7 @@ private struct StreamCardBackground: View {
private struct UserIdentityRow: View {
let userName: String
let playerName: String
+ let playbackDetails: PlexSessionPlaybackDetails
let resolvedLocation: String?
let thumb: String?
let serverURL: URL?
@@ -593,10 +636,7 @@ private struct UserIdentityRow: View {
.foregroundStyle(.primary)
.lineLimit(1)
- Text(playerName)
- .font(.caption)
- .foregroundStyle(.secondary)
- .lineLimit(1)
+ StreamDeviceSummaryView(playerName: playerName, details: playbackDetails)
if let resolvedLocation {
Text(resolvedLocation)
diff --git a/PlexBar/Views/StreamDetailsReveal.swift b/PlexBar/Views/StreamDetailsReveal.swift
new file mode 100644
index 0000000..ccfac48
--- /dev/null
+++ b/PlexBar/Views/StreamDetailsReveal.swift
@@ -0,0 +1,38 @@
+import SwiftUI
+
+struct StreamDetailsReveal: View {
+ let isExpanded: Bool
+ @ViewBuilder let content: Content
+
+ var body: some View {
+ StreamDetailsRevealLayout(progress: isExpanded ? 1 : 0) {
+ VStack(alignment: .leading, spacing: 0) {
+ content
+ }
+ }
+ .clipped()
+ .allowsHitTesting(isExpanded)
+ .accessibilityHidden(!isExpanded)
+ }
+}
+
+// Animate the measured height itself so the menu panel follows each frame,
+// rather than resizing to the final height before the details finish collapsing.
+@Animatable
+private struct StreamDetailsRevealLayout: Layout {
+ var progress: CGFloat
+
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
+ guard let content = subviews.first else { return .zero }
+ let size = content.sizeThatFits(ProposedViewSize(width: proposal.width, height: nil))
+ return CGSize(width: size.width, height: size.height * progress)
+ }
+
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
+ subviews.first?.place(
+ at: bounds.origin,
+ anchor: .topLeading,
+ proposal: ProposedViewSize(width: bounds.width, height: nil)
+ )
+ }
+}
diff --git a/PlexBar/Views/StreamPlaybackDetailsView.swift b/PlexBar/Views/StreamPlaybackDetailsView.swift
new file mode 100644
index 0000000..5c0a733
--- /dev/null
+++ b/PlexBar/Views/StreamPlaybackDetailsView.swift
@@ -0,0 +1,68 @@
+import SwiftUI
+
+struct StreamPlaybackDetailsView: View {
+ let details: PlexSessionPlaybackDetails
+
+ var body: some View {
+ Grid(alignment: .topLeading, horizontalSpacing: 16, verticalSpacing: 10) {
+ ForEach(details.rows) { row in
+ GridRow(alignment: .top) {
+ Text(row.title)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: true, vertical: false)
+ VStack(alignment: .leading, spacing: 3) {
+ Text(row.source)
+ if let output = row.output {
+ HStack(alignment: .firstTextBaseline, spacing: 6) {
+ Image(systemName: "arrow.turn.down.right")
+ .foregroundStyle(.secondary)
+ .accessibilityHidden(true)
+ Text(output)
+ .monospacedDigit()
+ }
+ .accessibilityLabel("Output: \(output)")
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .accessibilityElement(children: .combine)
+ }
+ if let bandwidth = details.bandwidth {
+ GridRow(alignment: .top) {
+ Text("Bandwidth").foregroundStyle(.secondary)
+ Text(bandwidth).monospacedDigit()
+ }
+ .accessibilityElement(children: .combine)
+ }
+ }
+ .font(.caption)
+ }
+}
+
+struct StreamDeviceSummaryView: View {
+ let playerName: String
+ let details: PlexSessionPlaybackDetails
+
+ private var method: String {
+ details.method + (details.usesHardware ? " · HW" : "")
+ }
+
+ var body: some View {
+ ViewThatFits(in: .horizontal) {
+ if let bandwidth = details.bandwidth {
+ Text("\(playerName) · \(method) · \(bandwidth)")
+ .fixedSize()
+ }
+ HStack(spacing: 0) {
+ Text(playerName)
+ .lineLimit(1)
+ Text(" · \(method)")
+ .fixedSize()
+ }
+ }
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
diff --git a/Sources/PlexBar/Views/UsersDashboardView.swift b/PlexBar/Views/UsersDashboardView.swift
similarity index 99%
rename from Sources/PlexBar/Views/UsersDashboardView.swift
rename to PlexBar/Views/UsersDashboardView.swift
index 257ac4c..5ba7f17 100644
--- a/Sources/PlexBar/Views/UsersDashboardView.swift
+++ b/PlexBar/Views/UsersDashboardView.swift
@@ -1,3 +1,4 @@
+import PlexModels
import SwiftUI
struct UsersDashboardView: View {
diff --git a/Sources/PlexBar/Views/WaveformProgressView.swift b/PlexBar/Views/WaveformProgressView.swift
similarity index 100%
rename from Sources/PlexBar/Views/WaveformProgressView.swift
rename to PlexBar/Views/WaveformProgressView.swift
diff --git a/PlexBarTests/Fixtures/Playback/playback-decision-direct-play.json b/PlexBarTests/Fixtures/Playback/playback-decision-direct-play.json
new file mode 100644
index 0000000..537319d
--- /dev/null
+++ b/PlexBarTests/Fixtures/Playback/playback-decision-direct-play.json
@@ -0,0 +1,62 @@
+{
+ "MediaContainer": {
+ "size": 1,
+ "generalDecisionCode": 1000,
+ "generalDecisionText": "Direct play OK.",
+ "directPlayDecisionCode": 1000,
+ "directPlayDecisionText": "Direct play OK.",
+ "identifier": "com.plexapp.plugins.library",
+ "Metadata": [
+ {
+ "ratingKey": "42001",
+ "key": "/library/metadata/42001",
+ "type": "movie",
+ "title": "Direct Play Fixture",
+ "duration": 634533,
+ "viewOffset": 12000,
+ "Media": [
+ {
+ "id": 61001,
+ "selected": true,
+ "container": "mp4",
+ "videoCodec": "h264",
+ "audioCodec": "aac",
+ "width": 1920,
+ "height": 1080,
+ "bitrate": 8000,
+ "duration": 634533,
+ "Part": [
+ {
+ "id": 71001,
+ "selected": true,
+ "decision": "directplay",
+ "key": "/library/parts/71001/fixture.mp4",
+ "container": "mp4",
+ "duration": 634533,
+ "Stream": [
+ {
+ "id": 81001,
+ "streamType": 1,
+ "codec": "h264",
+ "decision": "copy",
+ "location": "direct",
+ "selected": true
+ },
+ {
+ "id": 81002,
+ "streamType": 2,
+ "codec": "aac",
+ "channels": 2,
+ "decision": "copy",
+ "location": "direct",
+ "selected": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/PlexBarTests/Fixtures/Playback/playback-decision-direct-stream.json b/PlexBarTests/Fixtures/Playback/playback-decision-direct-stream.json
new file mode 100644
index 0000000..ba44057
--- /dev/null
+++ b/PlexBarTests/Fixtures/Playback/playback-decision-direct-stream.json
@@ -0,0 +1,61 @@
+{
+ "MediaContainer": {
+ "size": 1,
+ "generalDecisionCode": 1001,
+ "generalDecisionText": "Direct play not available; Conversion OK.",
+ "directPlayDecisionCode": 3000,
+ "directPlayDecisionText": "Direct play is disabled.",
+ "transcodeDecisionCode": 1001,
+ "transcodeDecisionText": "Direct play not available; Conversion OK.",
+ "identifier": "com.plexapp.plugins.library",
+ "Metadata": [
+ {
+ "ratingKey": "42002",
+ "key": "/library/metadata/42002",
+ "type": "movie",
+ "title": "Direct Stream Fixture",
+ "duration": 634533,
+ "Media": [
+ {
+ "id": 61002,
+ "selected": true,
+ "protocol": "hls",
+ "container": "mpegts",
+ "videoCodec": "h264",
+ "audioCodec": "aac",
+ "width": 1920,
+ "height": 1080,
+ "bitrate": 8000,
+ "Part": [
+ {
+ "id": 71002,
+ "selected": true,
+ "decision": "transcode",
+ "protocol": "hls",
+ "Stream": [
+ {
+ "id": 82001,
+ "streamType": 1,
+ "codec": "h264",
+ "decision": "copy",
+ "location": "segments-video",
+ "selected": true
+ },
+ {
+ "id": 82002,
+ "streamType": 2,
+ "codec": "aac",
+ "channels": 2,
+ "decision": "copy",
+ "location": "segments-audio",
+ "selected": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/PlexBarTests/Fixtures/Playback/playback-decision-music-transcode.json b/PlexBarTests/Fixtures/Playback/playback-decision-music-transcode.json
new file mode 100644
index 0000000..5e332a1
--- /dev/null
+++ b/PlexBarTests/Fixtures/Playback/playback-decision-music-transcode.json
@@ -0,0 +1,50 @@
+{
+ "MediaContainer": {
+ "generalDecisionCode": 1001,
+ "generalDecisionText": "Direct play not available; Conversion OK.",
+ "directPlayDecisionCode": 3004,
+ "directPlayDecisionText": "App cannot direct play this item. No direct play profile exists for flac.",
+ "transcodeDecisionCode": 1001,
+ "transcodeDecisionText": "Direct play not available; Conversion OK.",
+ "Metadata": [
+ {
+ "ratingKey": "91001",
+ "key": "/library/metadata/91001",
+ "type": "track",
+ "title": "Chapter 3",
+ "parentTitle": "Kitchen Confidential",
+ "grandparentTitle": "Anthony Bourdain",
+ "duration": 1800000,
+ "Media": [
+ {
+ "id": "92001",
+ "selected": true,
+ "container": "flac",
+ "audioCodec": "flac",
+ "bitrate": 921,
+ "duration": 1800000,
+ "Part": [
+ {
+ "id": "93001",
+ "selected": true,
+ "decision": "transcode",
+ "protocol": "hls",
+ "duration": 1800000,
+ "Stream": [
+ {
+ "id": "94001",
+ "selected": true,
+ "streamType": 2,
+ "codec": "aac",
+ "decision": "transcode",
+ "channels": 2
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/PlexBarTests/Fixtures/Playback/playback-decision-rejected.json b/PlexBarTests/Fixtures/Playback/playback-decision-rejected.json
new file mode 100644
index 0000000..93972a7
--- /dev/null
+++ b/PlexBarTests/Fixtures/Playback/playback-decision-rejected.json
@@ -0,0 +1,12 @@
+{
+ "MediaContainer": {
+ "size": 0,
+ "generalDecisionCode": 2000,
+ "generalDecisionText": "Playback is not possible for this item.",
+ "directPlayDecisionCode": 3000,
+ "directPlayDecisionText": "Direct play is unavailable.",
+ "transcodeDecisionCode": 4000,
+ "transcodeDecisionText": "Conversion is unavailable.",
+ "Metadata": []
+ }
+}
diff --git a/PlexBarTests/Fixtures/Playback/playback-decision-transcode.json b/PlexBarTests/Fixtures/Playback/playback-decision-transcode.json
new file mode 100644
index 0000000..0ebb525
--- /dev/null
+++ b/PlexBarTests/Fixtures/Playback/playback-decision-transcode.json
@@ -0,0 +1,69 @@
+{
+ "MediaContainer": {
+ "allowSync": "1",
+ "directPlayDecisionCode": 3000,
+ "directPlayDecisionText": "App cannot direct play this item. Direct play is disabled.",
+ "generalDecisionCode": 1001,
+ "generalDecisionText": "Direct play not available; Conversion OK.",
+ "identifier": "com.plexapp.plugins.library",
+ "librarySectionID": "60",
+ "librarySectionTitle": "Test Files",
+ "resourceSession": "E26A4C81-FB5E-4B49-BE2C-5973D7F5A98C",
+ "size": 1,
+ "transcodeDecisionCode": 1001,
+ "transcodeDecisionText": "Direct play not available; Conversion OK.",
+ "Metadata": [
+ {
+ "ratingKey": "151671",
+ "key": "/library/metadata/151671",
+ "type": "movie",
+ "subtype": "clip",
+ "title": "big-buck-bunny",
+ "duration": 634533,
+ "Media": [
+ {
+ "id": "221632",
+ "selected": true,
+ "protocol": "hls",
+ "container": "mkv",
+ "videoCodec": "h264",
+ "audioCodec": "opus",
+ "width": 1280,
+ "height": 720,
+ "bitrate": 3538,
+ "duration": 634533,
+ "Part": [
+ {
+ "id": "221638",
+ "selected": true,
+ "decision": "transcode",
+ "protocol": "hls",
+ "container": "mkv",
+ "duration": 634533,
+ "Stream": [
+ {
+ "id": "332899",
+ "streamType": 1,
+ "codec": "h264",
+ "decision": "transcode",
+ "location": "segments-av",
+ "selected": true
+ },
+ {
+ "id": "332900",
+ "streamType": 2,
+ "codec": "opus",
+ "channels": 2,
+ "decision": "transcode",
+ "location": "segments-av",
+ "selected": true
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/PlexBarTests/Fixtures/Playback/timeline-normal.json b/PlexBarTests/Fixtures/Playback/timeline-normal.json
new file mode 100644
index 0000000..d5d6d70
--- /dev/null
+++ b/PlexBarTests/Fixtures/Playback/timeline-normal.json
@@ -0,0 +1,5 @@
+{
+ "MediaContainer": {
+ "size": 0
+ }
+}
diff --git a/PlexBarTests/Fixtures/Playback/timeline-terminated.json b/PlexBarTests/Fixtures/Playback/timeline-terminated.json
new file mode 100644
index 0000000..7bc3d89
--- /dev/null
+++ b/PlexBarTests/Fixtures/Playback/timeline-terminated.json
@@ -0,0 +1,7 @@
+{
+ "MediaContainer": {
+ "size": 0,
+ "terminationCode": 2006,
+ "terminationText": "Admin terminated playback with reason: Go Away"
+ }
+}
diff --git a/PlexBarTests/KeychainStoreTests.swift b/PlexBarTests/KeychainStoreTests.swift
new file mode 100644
index 0000000..acf213c
--- /dev/null
+++ b/PlexBarTests/KeychainStoreTests.swift
@@ -0,0 +1,134 @@
+import Foundation
+import Security
+import Testing
+@testable import PlexBar
+
+private final class TestKeychainBackend: PlexKeychainBackend, @unchecked Sendable {
+ private let lock = NSLock()
+ private var values: [String: String] = [:]
+ private var activeCallCount = 0
+ private var recordedMaximumActiveCallCount = 0
+
+ var maximumActiveCallCount: Int {
+ lock.lock()
+ defer { lock.unlock() }
+ return recordedMaximumActiveCallCount
+ }
+
+ func read(service: String, account: String) -> String? {
+ beginCall()
+ defer { endCall() }
+ Thread.sleep(forTimeInterval: 0.001)
+
+ lock.lock()
+ defer { lock.unlock() }
+ return values[key(service: service, account: account)]
+ }
+
+ func write(_ value: String, service: String, account: String) {
+ beginCall()
+ defer { endCall() }
+ Thread.sleep(forTimeInterval: 0.001)
+
+ lock.lock()
+ values[key(service: service, account: account)] = value
+ lock.unlock()
+ }
+
+ func delete(service: String, account: String) {
+ beginCall()
+ defer { endCall() }
+ Thread.sleep(forTimeInterval: 0.001)
+
+ lock.lock()
+ values[key(service: service, account: account)] = nil
+ lock.unlock()
+ }
+
+ private func beginCall() {
+ lock.lock()
+ activeCallCount += 1
+ recordedMaximumActiveCallCount = max(recordedMaximumActiveCallCount, activeCallCount)
+ lock.unlock()
+ }
+
+ private func endCall() {
+ lock.lock()
+ activeCallCount -= 1
+ lock.unlock()
+ }
+
+ private func key(service: String, account: String) -> String {
+ "\(service)\u{0}\(account)"
+ }
+}
+
+struct KeychainStoreTests {
+ @Test func securityBackendKeepsReadControlsOutOfMutationQueries() {
+ let mutationQuery = PlexSecurityKeychainBackend.itemIdentityQuery(
+ service: "tests.keychain-query",
+ account: "credential"
+ )
+ let readQuery = PlexSecurityKeychainBackend.readQuery(
+ service: "tests.keychain-query",
+ account: "credential"
+ )
+
+ #expect(mutationQuery[kSecMatchLimit as String] == nil)
+ #expect(mutationQuery[kSecReturnData as String] == nil)
+ #expect(readQuery.keys.contains(kSecMatchLimit as String))
+ #expect(readQuery[kSecReturnData as String] as? Bool == true)
+ }
+
+ @Test func concurrentStoresCompleteThroughTheSharedAccessLane() async throws {
+ let runID = UUID().uuidString
+ let backend = TestKeychainBackend()
+ let accessLane = PlexKeychainAccessLane(backend: backend)
+ let stores = (0..<32).map { index in
+ KeychainStore(
+ service: "tests.keychain-lane.\(runID).\(index)",
+ accessLane: accessLane
+ )
+ }
+
+ try await withThrowingTaskGroup(of: Void.self) { group in
+ for (index, store) in stores.enumerated() {
+ group.addTask {
+ try await store.write("value-\(index)", account: "credential")
+ }
+ }
+ try await group.waitForAll()
+ }
+
+ let values = try await withThrowingTaskGroup(
+ of: (Int, String?).self,
+ returning: [Int: String?].self
+ ) { group in
+ for (index, store) in stores.enumerated() {
+ group.addTask {
+ (index, try await store.read(account: "credential"))
+ }
+ }
+
+ var collected: [Int: String?] = [:]
+ for try await (index, value) in group {
+ collected[index] = value
+ }
+ return collected
+ }
+
+ for index in stores.indices {
+ #expect(values[index] == "value-\(index)")
+ }
+ #expect(backend.maximumActiveCallCount == 1)
+
+ try await withThrowingTaskGroup(of: Void.self) { group in
+ for store in stores {
+ group.addTask {
+ try await store.delete(account: "credential")
+ }
+ }
+ try await group.waitForAll()
+ }
+ }
+}
diff --git a/PlexBarTests/NativePlaybackCapabilityProbeTests.swift b/PlexBarTests/NativePlaybackCapabilityProbeTests.swift
new file mode 100644
index 0000000..03927e9
--- /dev/null
+++ b/PlexBarTests/NativePlaybackCapabilityProbeTests.swift
@@ -0,0 +1,145 @@
+import CoreMedia
+import Testing
+@testable import PlexBar
+
+@Suite struct NativePlaybackCapabilityProbeTests {
+ @Test func requiresHardwareAndContainerCodecSupportForVideo() {
+ let capabilities = NativePlaybackCapabilityProbe.capabilities(
+ hardwareDecodeSupported: { codec in
+ codec == kCMVideoCodecType_H264 || codec == kCMVideoCodecType_AV1
+ },
+ playableExtendedMIMEType: { mimeType in
+ !mimeType.contains("av01")
+ }
+ )
+
+ #expect(capabilities.directPlayVideoCodecs == ["h264"])
+ }
+
+ @Test func advertisesAV1OnlyWhenBothNativeGatesPass() {
+ let capabilities = NativePlaybackCapabilityProbe.capabilities(
+ hardwareDecodeSupported: { $0 == kCMVideoCodecType_AV1 },
+ playableExtendedMIMEType: { $0.contains("av01") || $0.contains("mp4a.40.2") }
+ )
+
+ #expect(capabilities.directPlayVideoCodecs == ["av1"])
+ #expect(capabilities.clientProfileExtra(for: .video).contains("videoCodec=av1"))
+ }
+
+ @Test func derivesVideoAudioCodecsFromAVFoundationPlayability() {
+ let capabilities = NativePlaybackCapabilityProbe.capabilities(
+ hardwareDecodeSupported: { _ in false },
+ playableExtendedMIMEType: { mimeType in
+ mimeType.contains("mp4a.40.2") || mimeType.contains("ec-3") || mimeType.contains("Opus")
+ }
+ )
+
+ #expect(capabilities.directPlayAudioCodecs == ["aac", "eac3", "opus"])
+ }
+
+ @Test(arguments: [
+ (true, true, "h264,hevc"),
+ (false, true, "h264"),
+ (true, false, "h264"),
+ ])
+ func hlsPreservesHEVCOnlyWithNativeDecodeAndMP4Support(
+ hardwareSupportsHEVC: Bool,
+ mp4SupportsHEVC: Bool,
+ expectedVideoCodecs: String
+ ) {
+ let capabilities = NativePlaybackCapabilityProbe.capabilities(
+ hardwareDecodeSupported: { codec in
+ codec != kCMVideoCodecType_HEVC || hardwareSupportsHEVC
+ },
+ playableExtendedMIMEType: { mimeType in
+ !mimeType.contains("hvc1") || mp4SupportsHEVC
+ }
+ )
+
+ let profile = capabilities.clientProfileExtra(for: .video)
+ #expect(profile.contains(
+ "add-transcode-target(type=videoProfile&context=streaming&protocol=hls" +
+ "&container=mp4&videoCodec=\(expectedVideoCodecs)&audioCodec=aac&replace=true)"
+ ))
+ // A codec supported for files (such as AV1) is not automatically an HLS codec.
+ #expect(!profile.contains("container=mp4&videoCodec=h264,hevc,av1"))
+ #expect(!profile.contains("container=mpegts"))
+ #expect(capabilities.clientProfileExtra(for: .music).contains("container=mpegts"))
+ }
+
+ @Test func hlsDolbySupportRequiresTheMP4ContainerUsedForDelivery() {
+ let capabilities = NativePlaybackCapabilityProbe.capabilities(
+ hardwareDecodeSupported: { _ in true },
+ playableExtendedMIMEType: { $0.hasPrefix("video/mp2t;") }
+ )
+
+ #expect(capabilities.hlsStreamingAudioCodecs.isEmpty)
+ }
+
+ @Test func advertisesOnlyVerifiedDolbyCodecsForHLSStreaming() {
+ let capabilities = NativePlaybackCapabilityProbe.capabilities(
+ hardwareDecodeSupported: { _ in false },
+ playableExtendedMIMEType: { mimeType in
+ mimeType == #"video/mp4; codecs="avc1.640028, ec-3""#
+ }
+ )
+
+ #expect(capabilities.hlsStreamingAudioCodecs == ["eac3"])
+ #expect(capabilities.clientProfileExtra(for: .video).contains(
+ "add-transcode-target-codec(type=videoProfile&context=streaming" +
+ "&protocol=hls&audioCodec=eac3)"
+ ))
+ }
+
+ @Test func omitsHLSAudioAugmentationWithoutNativeContainerCodecSupport() {
+ let capabilities = NativePlaybackCapabilityProbe.capabilities(
+ hardwareDecodeSupported: { _ in false },
+ playableExtendedMIMEType: { _ in false }
+ )
+
+ #expect(capabilities.hlsStreamingAudioCodecs.isEmpty)
+ #expect(!capabilities.clientProfileExtra(for: .video).contains(
+ "add-transcode-target-codec"
+ ))
+ }
+
+ @Test func derivesExactMusicContainerCodecPairsFromAudioMIMEPlayability() {
+ let capabilities = NativePlaybackCapabilityProbe.capabilities(
+ hardwareDecodeSupported: { _ in false },
+ playableExtendedMIMEType: { mimeType in
+ mimeType == #"audio/mpeg; codecs="mp3""#
+ || mimeType == #"audio/mp4; codecs="alac""#
+ }
+ )
+
+ #expect(capabilities.directPlayMusicProfiles == [
+ PlexMusicDirectPlayProfile(container: "mp3", audioCodec: "mp3"),
+ PlexMusicDirectPlayProfile(container: "mp4", audioCodec: "alac"),
+ ])
+ let profile = capabilities.clientProfileExtra(for: .music)
+ #expect(profile.contains("type=musicProfile&container=mp3"))
+ #expect(profile.contains("type=musicProfile&container=mp4"))
+ #expect(!profile.contains("type=videoProfile"))
+ }
+
+ @Test func downloadProfilesUseNativeHTTPMP4ConversionTargets() {
+ let capabilities = PlexPlaybackCapabilities(
+ directPlayContainers: ["mp4"],
+ directPlayVideoCodecs: ["h264"],
+ directPlayAudioCodecs: ["aac"],
+ directPlayMusicProfiles: [
+ PlexMusicDirectPlayProfile(container: "mp4", audioCodec: "aac")
+ ]
+ )
+
+ let video = capabilities.downloadClientProfileExtra(for: .video)
+ #expect(video.contains("type=videoProfile&context=static&protocol=http"))
+ #expect(video.contains(
+ "container=mp4&videoCodec=h264&audioCodec=aac&subtitleCodec=mov_text&replace=true"
+ ))
+
+ let music = capabilities.downloadClientProfileExtra(for: .music)
+ #expect(music.contains("type=musicProfile&context=static&protocol=http"))
+ #expect(music.contains("container=mp4&audioCodec=aac&replace=true"))
+ }
+}
diff --git a/PlexBarTests/PlexAccessibilityPresentationTests.swift b/PlexBarTests/PlexAccessibilityPresentationTests.swift
new file mode 100644
index 0000000..617de52
--- /dev/null
+++ b/PlexBarTests/PlexAccessibilityPresentationTests.swift
@@ -0,0 +1,85 @@
+import PlexModels
+import Foundation
+import SwiftUI
+import Testing
+@testable import PlexBar
+
+struct PlexAccessibilityPresentationTests {
+ @Test func increasedContrastSubduesArtworkAndStrengthensTheSemanticFade() {
+ for colorScheme in [ColorScheme.light, .dark] {
+ let standard = PlexArtworkBackdropStyle(
+ colorScheme: colorScheme,
+ contrast: .standard
+ )
+ let increased = PlexArtworkBackdropStyle(
+ colorScheme: colorScheme,
+ contrast: .increased
+ )
+
+ #expect(increased.paletteOpacity < standard.paletteOpacity)
+ #expect(increased.topFadeOpacity > standard.topFadeOpacity)
+ #expect(increased.middleFadeOpacity > standard.middleFadeOpacity)
+ #expect(increased.bottomFadeOpacity > standard.bottomFadeOpacity)
+ }
+ }
+
+ @Test func backdropNeverRemovesTheSemanticWindowBackground() {
+ for colorScheme in [ColorScheme.light, .dark] {
+ for contrast in [ColorSchemeContrast.standard, .increased] {
+ let style = PlexArtworkBackdropStyle(
+ colorScheme: colorScheme,
+ contrast: contrast
+ )
+
+ #expect(style.topFadeOpacity > 0)
+ #expect(style.middleFadeOpacity >= style.topFadeOpacity)
+ #expect(style.bottomFadeOpacity >= style.middleFadeOpacity)
+ #expect(style.bottomFadeOpacity <= 1)
+ }
+ }
+ }
+
+ @Test func increasedContrastStrengthensCinematicHeroReadabilityWithoutChangingLayout() {
+ let standard = PlexCinematicHeroStyle(contrast: .standard)
+ let increased = PlexCinematicHeroStyle(contrast: .increased)
+
+ #expect(increased.blurSaturation < standard.blurSaturation)
+ #expect(increased.blurDarkeningOpacity > standard.blurDarkeningOpacity)
+ #expect(increased.upperScrimOpacity > standard.upperScrimOpacity)
+ #expect(increased.contentScrimOpacity > standard.contentScrimOpacity)
+ #expect(increased.lowerScrimOpacity > standard.lowerScrimOpacity)
+ }
+
+ @Test func increasedContrastStrengthensOnlyThePlayerHUDBackgroundScrim() {
+ let standard = PlexPlayerOverlayStyle(contrast: .standard)
+ let increased = PlexPlayerOverlayStyle(contrast: .increased)
+
+ #expect(standard.backgroundScrimOpacity == 0.14)
+ #expect(increased.backgroundScrimOpacity > standard.backgroundScrimOpacity)
+ #expect(increased.backgroundScrimOpacity < 1)
+ }
+
+ @Test func mediaCardsExposeWatchedProgressAndUnwatchedStateWithoutRelyingOnColor() throws {
+ let watched = try decodeMediaItem(
+ #"{"ratingKey":"1","type":"movie","title":"Watched","viewCount":1}"#
+ )
+ let inProgress = try decodeMediaItem(
+ #"{"ratingKey":"2","type":"episode","title":"In Progress","duration":400000,"viewOffset":100000}"#
+ )
+ let unwatched = try decodeMediaItem(
+ #"{"ratingKey":"3","type":"movie","title":"Unwatched","viewCount":0}"#
+ )
+ let unsupported = try decodeMediaItem(
+ #"{"ratingKey":"4","type":"clip","title":"Extra","viewCount":0}"#
+ )
+
+ #expect(watched.watchStateAccessibilityValue == "Watched")
+ #expect(inProgress.watchStateAccessibilityValue == "25% watched")
+ #expect(unwatched.watchStateAccessibilityValue == "Unwatched")
+ #expect(unsupported.watchStateAccessibilityValue == nil)
+ }
+
+ private func decodeMediaItem(_ json: String) throws -> PlexMediaItem {
+ try JSONDecoder().decode(PlexMediaItem.self, from: Data(json.utf8))
+ }
+}
diff --git a/PlexBarTests/PlexAccountJWTManagerTests.swift b/PlexBarTests/PlexAccountJWTManagerTests.swift
new file mode 100644
index 0000000..56509b4
--- /dev/null
+++ b/PlexBarTests/PlexAccountJWTManagerTests.swift
@@ -0,0 +1,427 @@
+import Foundation
+import Testing
+@testable import PlexBar
+
+private enum JWTClientCall: Equatable, Sendable {
+ case registerJWK(legacyToken: String, use: String?, clientIdentifier: String)
+ case fetchNonce(clientIdentifier: String)
+ case exchange(deviceJWT: String, clientIdentifier: String)
+}
+
+private struct JWTClientTestError: Error {}
+
+private struct JWTCredentialPersistenceTestError: Error {}
+
+private actor RejectingJWTCredentialStore: PlexCredentialPersisting {
+ func loadCredentials() -> PlexStoredCredentials {
+ .empty
+ }
+
+ func replace(_ value: String?, account: String) throws {
+ throw JWTCredentialPersistenceTestError()
+ }
+}
+
+private actor RecordingAccountJWTClient: PlexAccountJWTClient {
+ private let nonce: String
+ private let exchangedToken: String
+ private let registerError: Error?
+ private let exchangeError: Error?
+ private var calls: [JWTClientCall] = []
+
+ init(
+ nonce: String = "test-nonce",
+ exchangedToken: String,
+ registerError: Error? = nil,
+ exchangeError: Error? = nil
+ ) {
+ self.nonce = nonce
+ self.exchangedToken = exchangedToken
+ self.registerError = registerError
+ self.exchangeError = exchangeError
+ }
+
+ func registerJWK(
+ _ jwk: PlexJSONWebKey,
+ legacyToken: String,
+ clientContext: PlexClientContext
+ ) throws {
+ calls.append(.registerJWK(
+ legacyToken: legacyToken,
+ use: jwk.use,
+ clientIdentifier: clientContext.clientIdentifier
+ ))
+ if let registerError {
+ throw registerError
+ }
+ }
+
+ func fetchJWTNonce(clientContext: PlexClientContext) throws -> String {
+ calls.append(.fetchNonce(clientIdentifier: clientContext.clientIdentifier))
+ return nonce
+ }
+
+ func exchangeDeviceJWT(
+ _ deviceJWT: String,
+ clientContext: PlexClientContext
+ ) throws -> String {
+ calls.append(.exchange(
+ deviceJWT: deviceJWT,
+ clientIdentifier: clientContext.clientIdentifier
+ ))
+ if let exchangeError {
+ throw exchangeError
+ }
+ return exchangedToken
+ }
+
+ func recordedCalls() -> [JWTClientCall] {
+ calls
+ }
+}
+
+@MainActor
+struct PlexAccountJWTManagerTests {
+ private let now = Date(timeIntervalSince1970: 2_000_000_000)
+
+ @Test func migratesLegacyTokenAndPersistsRegistrationCheckpoint() async throws {
+ let testState = try makeSettings(token: "legacy-account-token")
+ defer { testState.cleanup() }
+ let issuedToken = try accountJWT(expiration: now.addingTimeInterval(7 * 24 * 60 * 60))
+ let client = RecordingAccountJWTClient(exchangedToken: issuedToken)
+ let identity = try PlexDeviceSigningIdentity.generate(keyID: "device-key")
+ let manager = makeManager(settings: testState.store, client: client, identity: identity)
+
+ let preparedToken = try await manager.prepareAccountToken()
+
+ #expect(preparedToken.token == issuedToken)
+ #expect(preparedToken.refreshAt == preparedToken.expiresAt.addingTimeInterval(-24 * 60 * 60))
+ #expect(testState.store.userToken == issuedToken)
+ #expect(testState.store.registeredJWTKeyID == "device-key")
+ let calls = await client.recordedCalls()
+ #expect(calls.count == 3)
+ #expect(calls[0] == .registerJWK(
+ legacyToken: "legacy-account-token",
+ use: "sig",
+ clientIdentifier: testState.store.clientIdentifier
+ ))
+ #expect(calls[1] == .fetchNonce(clientIdentifier: testState.store.clientIdentifier))
+ guard case .exchange(let deviceJWT, let clientIdentifier) = calls[2] else {
+ Issue.record("Expected device JWT exchange")
+ return
+ }
+ #expect(clientIdentifier == testState.store.clientIdentifier)
+ #expect(deviceJWT.split(separator: ".").count == 3)
+
+ let reloadedStore = PlexSettingsStore(
+ defaults: testState.defaults,
+ initialCredentials: PlexStoredCredentials(userToken: issuedToken, serverToken: "")
+ )
+ #expect(reloadedStore.registeredJWTKeyID == "device-key")
+ }
+
+ @Test func failedExchangeKeepsLegacyTokenButPersistsRegistration() async throws {
+ let testState = try makeSettings(token: "legacy-account-token")
+ defer { testState.cleanup() }
+ let fallbackToken = try accountJWT(expiration: now.addingTimeInterval(7 * 24 * 60 * 60))
+ let client = RecordingAccountJWTClient(
+ exchangedToken: fallbackToken,
+ exchangeError: JWTClientTestError()
+ )
+ let identity = try PlexDeviceSigningIdentity.generate(keyID: "device-key")
+ let manager = makeManager(settings: testState.store, client: client, identity: identity)
+
+ await #expect(throws: JWTClientTestError.self) {
+ _ = try await manager.prepareAccountToken()
+ }
+
+ #expect(testState.store.registeredJWTKeyID == "device-key")
+ #expect(testState.store.userToken == "legacy-account-token")
+ #expect(await client.recordedCalls().count == 3)
+ }
+
+ @Test func failedRegistrationKeepsLegacyTokenAndDoesNotPersistCheckpoint() async throws {
+ let testState = try makeSettings(token: "legacy-account-token")
+ defer { testState.cleanup() }
+ let issuedToken = try accountJWT(expiration: now.addingTimeInterval(7 * 24 * 60 * 60))
+ let client = RecordingAccountJWTClient(
+ exchangedToken: issuedToken,
+ registerError: JWTClientTestError()
+ )
+ let identity = try PlexDeviceSigningIdentity.generate(keyID: "device-key")
+ let manager = makeManager(settings: testState.store, client: client, identity: identity)
+
+ await #expect(throws: JWTClientTestError.self) {
+ _ = try await manager.prepareAccountToken()
+ }
+
+ #expect(testState.store.registeredJWTKeyID == nil)
+ #expect(testState.store.userToken == "legacy-account-token")
+ #expect(await client.recordedCalls() == [
+ .registerJWK(
+ legacyToken: "legacy-account-token",
+ use: "sig",
+ clientIdentifier: testState.store.clientIdentifier
+ ),
+ ])
+ }
+
+ @Test func resumesRegisteredLegacyMigrationWithoutRegisteringAgain() async throws {
+ let testState = try makeSettings(token: "legacy-account-token")
+ defer { testState.cleanup() }
+ testState.store.markJWTKeyRegistered(keyID: "device-key")
+ let issuedToken = try accountJWT(expiration: now.addingTimeInterval(7 * 24 * 60 * 60))
+ let client = RecordingAccountJWTClient(exchangedToken: issuedToken)
+ let identity = try PlexDeviceSigningIdentity.generate(keyID: "device-key")
+ let manager = makeManager(settings: testState.store, client: client, identity: identity)
+
+ _ = try await manager.prepareAccountToken()
+
+ let calls = await client.recordedCalls()
+ #expect(calls.count == 2)
+ #expect(calls[0] == .fetchNonce(clientIdentifier: testState.store.clientIdentifier))
+ guard case .exchange = calls[1] else {
+ Issue.record("Expected device JWT exchange")
+ return
+ }
+ }
+
+ @Test func registersAgainWhenCheckpointBelongsToDifferentKey() async throws {
+ let testState = try makeSettings(token: "legacy-account-token")
+ defer { testState.cleanup() }
+ testState.store.markJWTKeyRegistered(keyID: "old-device-key")
+ let issuedToken = try accountJWT(expiration: now.addingTimeInterval(7 * 24 * 60 * 60))
+ let client = RecordingAccountJWTClient(exchangedToken: issuedToken)
+ let identity = try PlexDeviceSigningIdentity.generate(keyID: "new-device-key")
+ let manager = makeManager(settings: testState.store, client: client, identity: identity)
+
+ _ = try await manager.prepareAccountToken()
+
+ #expect(testState.store.registeredJWTKeyID == "new-device-key")
+ let calls = await client.recordedCalls()
+ #expect(calls.count == 3)
+ #expect(calls[0] == .registerJWK(
+ legacyToken: "legacy-account-token",
+ use: "sig",
+ clientIdentifier: testState.store.clientIdentifier
+ ))
+ }
+
+ @Test func reusesJWTOutsideRefreshWindowWithoutNetworkWork() async throws {
+ let storedToken = try accountJWT(expiration: now.addingTimeInterval(3 * 24 * 60 * 60))
+ let testState = try makeSettings(token: storedToken)
+ defer { testState.cleanup() }
+ let client = RecordingAccountJWTClient(exchangedToken: storedToken)
+ let manager = makeManager(settings: testState.store, client: client)
+
+ let preparedToken = try await manager.prepareAccountToken()
+
+ #expect(preparedToken.token == storedToken)
+ #expect(testState.store.registeredJWTKeyID == nil)
+ #expect(await client.recordedCalls().isEmpty)
+ }
+
+ @Test func refreshesFutureDatedJWTAfterPlexRejectsIt() async throws {
+ let rejectedToken = try accountJWT(expiration: now.addingTimeInterval(3 * 24 * 60 * 60))
+ let issuedToken = try accountJWT(expiration: now.addingTimeInterval(7 * 24 * 60 * 60))
+ let testState = try makeSettings(token: rejectedToken)
+ defer { testState.cleanup() }
+ let client = RecordingAccountJWTClient(exchangedToken: issuedToken)
+ let identity = try PlexDeviceSigningIdentity.generate(keyID: "device-key")
+ let manager = makeManager(settings: testState.store, client: client, identity: identity)
+
+ let preparedToken = try await manager.recoverRejectedAccountToken(rejectedToken)
+
+ #expect(preparedToken.token == issuedToken)
+ #expect(testState.store.userToken == issuedToken)
+ #expect(await client.recordedCalls().count == 2)
+ }
+
+ @Test func refreshesJWTInsideRefreshWindow() async throws {
+ let storedToken = try accountJWT(expiration: now.addingTimeInterval(60 * 60))
+ let issuedToken = try accountJWT(expiration: now.addingTimeInterval(7 * 24 * 60 * 60))
+ let testState = try makeSettings(token: storedToken)
+ defer { testState.cleanup() }
+ let client = RecordingAccountJWTClient(exchangedToken: issuedToken)
+ let identity = try PlexDeviceSigningIdentity.generate(keyID: "device-key")
+ let manager = makeManager(settings: testState.store, client: client, identity: identity)
+
+ let preparedToken = try await manager.prepareAccountToken()
+
+ #expect(preparedToken.token == issuedToken)
+ #expect(testState.store.registeredJWTKeyID == "device-key")
+ let calls = await client.recordedCalls()
+ #expect(calls.count == 2)
+ #expect(calls[0] == .fetchNonce(clientIdentifier: testState.store.clientIdentifier))
+ guard case .exchange = calls[1] else {
+ Issue.record("Expected device JWT exchange")
+ return
+ }
+ }
+
+ @Test func refreshesExpiredJWTBeforeReturningItForAccountRequests() async throws {
+ let expiredToken = try accountJWT(expiration: now.addingTimeInterval(-60))
+ let issuedToken = try accountJWT(expiration: now.addingTimeInterval(7 * 24 * 60 * 60))
+ let testState = try makeSettings(token: expiredToken)
+ defer { testState.cleanup() }
+ let client = RecordingAccountJWTClient(exchangedToken: issuedToken)
+ let identity = try PlexDeviceSigningIdentity.generate(keyID: "device-key")
+ let manager = makeManager(settings: testState.store, client: client, identity: identity)
+
+ let preparedToken = try await manager.prepareAccountToken()
+
+ #expect(preparedToken.token == issuedToken)
+ #expect(testState.store.userToken == issuedToken)
+ #expect(await client.recordedCalls().count == 2)
+ }
+
+ @Test func accountTokenMigrationPreservesResourceServerToken() async throws {
+ let testState = try makeSettings(
+ token: "legacy-account-token",
+ serverToken: "resource-server-token"
+ )
+ defer { testState.cleanup() }
+ let issuedToken = try accountJWT(expiration: now.addingTimeInterval(7 * 24 * 60 * 60))
+ let client = RecordingAccountJWTClient(exchangedToken: issuedToken)
+ let identity = try PlexDeviceSigningIdentity.generate(keyID: "device-key")
+ let manager = makeManager(settings: testState.store, client: client, identity: identity)
+
+ _ = try await manager.prepareAccountToken()
+
+ #expect(testState.store.userToken == issuedToken)
+ #expect(testState.store.serverToken == "resource-server-token")
+ }
+
+ @Test func acceptsPinIssuedJWTWithItsRegisteredKeyIdentity() async throws {
+ let testState = try makeSettings(token: "")
+ defer { testState.cleanup() }
+ let issuedToken = try accountJWT(expiration: now.addingTimeInterval(7 * 24 * 60 * 60))
+ let client = RecordingAccountJWTClient(exchangedToken: issuedToken)
+ let manager = makeManager(settings: testState.store, client: client)
+
+ let preparedToken = try await manager.acceptNewAccountToken(
+ issuedToken,
+ registeredKeyID: "pin-device-key"
+ )
+
+ #expect(preparedToken.token == issuedToken)
+ #expect(testState.store.userToken == issuedToken)
+ #expect(testState.store.registeredJWTKeyID == "pin-device-key")
+ }
+
+ @Test func pinIssuedJWTIsNotPublishedWhenDurablePersistenceFails() async throws {
+ let suiteName = "PlexBarTests.PlexAccountJWTManager.persistenceFailure.\(UUID().uuidString)"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ let settings = PlexSettingsStore(
+ defaults: defaults,
+ credentialStore: RejectingJWTCredentialStore(),
+ initialCredentials: .empty
+ )
+ let issuedToken = try accountJWT(expiration: now.addingTimeInterval(7 * 24 * 60 * 60))
+ let manager = makeManager(
+ settings: settings,
+ client: RecordingAccountJWTClient(exchangedToken: issuedToken)
+ )
+
+ await #expect(throws: JWTCredentialPersistenceTestError.self) {
+ _ = try await manager.acceptNewAccountToken(
+ issuedToken,
+ registeredKeyID: "pin-device-key"
+ )
+ }
+
+ #expect(settings.userToken.isEmpty)
+ #expect(!settings.hasAuthenticatedAccount)
+ #expect(settings.registeredJWTKeyID == "pin-device-key")
+ }
+
+ @Test func rejectsLegacyTokenReturnedByExchange() async throws {
+ let testState = try makeSettings(token: "legacy-account-token")
+ defer { testState.cleanup() }
+ let client = RecordingAccountJWTClient(exchangedToken: "another-legacy-token")
+ let manager = makeManager(settings: testState.store, client: client)
+
+ await #expect(throws: PlexJWTError.self) {
+ _ = try await manager.prepareAccountToken()
+ }
+
+ #expect(testState.store.userToken == "legacy-account-token")
+ }
+
+ @Test func rejectsIssuedJWTThatWouldImmediatelyNeedRefresh() async throws {
+ let testState = try makeSettings(token: "legacy-account-token")
+ defer { testState.cleanup() }
+ let shortToken = try accountJWT(expiration: now.addingTimeInterval(60 * 60))
+ let client = RecordingAccountJWTClient(exchangedToken: shortToken)
+ let manager = makeManager(settings: testState.store, client: client)
+
+ await #expect(throws: PlexJWTError.self) {
+ _ = try await manager.prepareAccountToken()
+ }
+
+ #expect(testState.store.userToken == "legacy-account-token")
+ }
+
+ private func makeManager(
+ settings: PlexSettingsStore,
+ client: RecordingAccountJWTClient,
+ identity: PlexDeviceSigningIdentity? = nil
+ ) -> PlexAccountJWTManager {
+ PlexAccountJWTManager(
+ storage: settings,
+ client: client,
+ deviceIdentityStore: PlexMemoryDeviceIdentityStore(identity: identity),
+ now: { now }
+ )
+ }
+
+ private func makeSettings(
+ token: String,
+ serverToken: String = ""
+ ) throws -> JWTManagerTestState {
+ let suiteName = "PlexBarTests.PlexAccountJWTManager.\(UUID().uuidString)"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ return JWTManagerTestState(
+ store: PlexSettingsStore(
+ defaults: defaults,
+ credentialStore: PlexMemoryCredentialStore(credentials: PlexStoredCredentials(
+ userToken: token,
+ serverToken: serverToken
+ )),
+ initialCredentials: PlexStoredCredentials(
+ userToken: token,
+ serverToken: serverToken
+ )
+ ),
+ defaults: defaults,
+ suiteName: suiteName
+ )
+ }
+
+ private func accountJWT(expiration: Date) throws -> String {
+ let header = try JSONSerialization.data(withJSONObject: ["alg": "EdDSA"])
+ let payload = try JSONSerialization.data(withJSONObject: ["exp": Int(expiration.timeIntervalSince1970)])
+ return "\(base64URL(header)).\(base64URL(payload)).test-signature"
+ }
+
+ private func base64URL(_ data: Data) -> String {
+ data.base64EncodedString()
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: "")
+ }
+}
+
+@MainActor
+private struct JWTManagerTestState {
+ let store: PlexSettingsStore
+ let defaults: UserDefaults
+ let suiteName: String
+
+ func cleanup() {
+ defaults.removePersistentDomain(forName: suiteName)
+ }
+}
diff --git a/PlexBarTests/PlexAccountScopedStateTests.swift b/PlexBarTests/PlexAccountScopedStateTests.swift
new file mode 100644
index 0000000..b8bace0
--- /dev/null
+++ b/PlexBarTests/PlexAccountScopedStateTests.swift
@@ -0,0 +1,119 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+@MainActor
+struct PlexAccountScopedStateTests {
+ @Test func accountChangeResetClearsEveryPublishedServerScopedStore() throws {
+ let suiteName = "PlexBarTests.accountChangeResetClearsEveryPublishedServerScopedStore"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let credentials = PlexStoredCredentials(
+ userToken: "user-token",
+ serverToken: "server-token"
+ )
+ let settings = PlexSettingsStore(
+ defaults: defaults,
+ credentialStore: PlexMemoryCredentialStore(credentials: credentials),
+ initialCredentials: credentials
+ )
+ settings.selectedServerIdentifier = "server-id"
+ let connectionStore = PlexConnectionStore(settings: settings)
+ let libraryStore = PlexLibraryStore(connectionStore: connectionStore)
+ let historyStore = PlexHistoryStore(
+ connectionStore: connectionStore,
+ libraryStore: libraryStore,
+ startsPolling: false
+ )
+ let browserStore = PlexBrowserStore(connectionStore: connectionStore)
+
+ libraryStore.libraries = [sampleLibrary]
+ libraryStore.errorMessage = "Old library error"
+ libraryStore.lastUpdated = Date()
+
+ historyStore.recentItems = [sampleHistoryItem]
+ historyStore.accountsByID = [7: PlexAccount(id: 7, name: "Previous User", thumb: nil)]
+ historyStore.errorMessage = "Old history error"
+ historyStore.lastUpdated = Date()
+
+ browserStore.homeState.hasLoadedHubs = true
+ browserStore.homeState.hubsErrorMessage = "Old home error"
+ browserStore.playlistsTotalSize = 1
+ browserStore.playlistsErrorMessage = "Old playlist error"
+ browserStore.libraryFilterValuesByCacheKey = [
+ "old-scope": [
+ PlexLibraryFilterValue(
+ filterID: "genre",
+ queryName: "genre",
+ queryValue: "1",
+ title: "Drama"
+ )
+ ]
+ ]
+
+ browserStore.resetServerScopedState()
+ libraryStore.resetServerScopedState()
+ historyStore.resetServerScopedState()
+
+ #expect(!browserStore.hasLoadedHomeHubs)
+ #expect(browserStore.homeHubsErrorMessage == nil)
+ #expect(browserStore.playlists.isEmpty)
+ #expect(browserStore.playlistsTotalSize == nil)
+ #expect(browserStore.playlistsErrorMessage == nil)
+ #expect(browserStore.libraryFilterValuesByCacheKey.isEmpty)
+ #expect(browserStore.cacheMetrics.libraryRequestCount == 0)
+
+ #expect(libraryStore.libraries.isEmpty)
+ #expect(libraryStore.errorMessage == nil)
+ #expect(libraryStore.lastUpdated == nil)
+
+ #expect(historyStore.recentItems.isEmpty)
+ #expect(historyStore.accountsByID.isEmpty)
+ #expect(historyStore.devicesByID.isEmpty)
+ #expect(historyStore.errorMessage == nil)
+ #expect(historyStore.lastUpdated == nil)
+ }
+
+ private var sampleLibrary: PlexLibrary {
+ PlexLibrary(
+ id: "2",
+ title: "Movies",
+ type: .movie,
+ compositePath: nil,
+ artPath: nil,
+ thumbPath: nil,
+ itemCount: 1,
+ secondaryCount: nil,
+ secondaryCountLabel: nil,
+ updatedAt: nil,
+ scannedAt: nil,
+ contentChangedAt: nil,
+ latestAddedAt: nil,
+ latestItemTitle: nil
+ )
+ }
+
+ private var sampleHistoryItem: PlexHistoryItem {
+ PlexHistoryItem(
+ historyKey: "/status/sessions/history/1",
+ key: "/library/metadata/1",
+ ratingKey: "1",
+ title: "Previous Account Movie",
+ type: "movie",
+ thumb: nil,
+ parentThumb: nil,
+ grandparentThumb: nil,
+ art: nil,
+ grandparentTitle: nil,
+ parentTitle: nil,
+ parentIndex: nil,
+ index: nil,
+ originallyAvailableAt: nil,
+ viewedAt: Date(),
+ accountID: 7
+ )
+ }
+}
diff --git a/PlexBarTests/PlexActivitySummaryTests.swift b/PlexBarTests/PlexActivitySummaryTests.swift
new file mode 100644
index 0000000..f36bc17
--- /dev/null
+++ b/PlexBarTests/PlexActivitySummaryTests.swift
@@ -0,0 +1,217 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+struct PlexActivitySummaryTests {
+ @Test func mockServerExercisesAllKnownDeliveryMethods() async throws {
+ let client = PlexAPIClient(session: PlexDebugMockServer.makeSession())
+ let sessions = try await client.fetchSessions(using: PlexConnectionConfiguration(
+ serverURL: PlexDebugMockServer.mockResolvedConnection.url,
+ token: "plexbar-debug-mock-server-token",
+ clientContext: PlexClientContext(clientIdentifier: "activity-tests")
+ ))
+ let summary = PlexActivitySummary(sessions: sessions)
+ #expect(summary.streamCount == 4)
+ #expect(summary.directPlayCount == 2)
+ #expect(summary.directStreamCount == 1)
+ #expect(summary.transcodingCount == 1)
+ #expect(summary.unknownCount == 0)
+ #expect(summary.totalBandwidthKbps == 30920)
+ }
+
+ @Test func mixedSessionsCountOnceAndSumReportedBandwidth() throws {
+ let sessions = try [
+ decodeSession(part: #"{"decision":"directplay"}"#, bandwidth: 13900, location: "wan"),
+ decodeSession(part: #"{"decision":"transcode","Stream":[{"streamType":1,"decision":"transcode"},{"streamType":2,"selected":true,"decision":"transcode"}]}"#, bandwidth: 21300, location: "wan"),
+ decodeSession(part: #"{"decision":"transcode","Stream":[{"streamType":1,"decision":"copy"},{"streamType":2,"selected":true,"decision":"transcode"}]}"#, bandwidth: 9500, location: "lan", state: "paused")
+ ]
+ let summary = PlexActivitySummary(sessions: sessions)
+ #expect(summary.streamCount == 3)
+ #expect(summary.directPlayCount == 1)
+ #expect(summary.transcodingCount == 2)
+ #expect(summary.directStreamCount == 0)
+ #expect(summary.unknownCount == 0)
+ #expect(summary.totalBandwidthKbps == 44700)
+ #expect(summary.localBandwidthKbps == 9500)
+ #expect(summary.remoteBandwidthKbps == 35200)
+ #expect(!summary.hasPartialBandwidth)
+ }
+
+ @Test(arguments: [
+ (#"{"decision":"transcode","Stream":[{"streamType":1,"decision":"copy"},{"streamType":2,"decision":"copy"}]}"#, PlexSessionDeliveryMethod.directStream),
+ (#"{"decision":"transcode","Stream":[{"streamType":2,"decision":"copy"}]}"#, .directStream),
+ (#"{"decision":"transcode","Stream":[{"streamType":2,"decision":"transcode"}]}"#, .transcoding),
+ (#"{"decision":"transcode","Stream":[{"streamType":1,"decision":"transcode"},{"streamType":3,"selected":true,"decision":"burn"}]}"#, .transcoding),
+ (#"{"decision":"transcode","Stream":[{"streamType":1,"decision":"copy"},{"streamType":3,"selected":true,"decision":"transcode"}]}"#, .directStream),
+ (#"{"decision":"transcode"}"#, .unknown),
+ (#"{"decision":"transcode","Stream":[{"streamType":1,"decision":"copy"},{"streamType":2}]}"#, .directStream),
+ (#"{"decision":"future-value"}"#, .unknown),
+ (#"{}"#, .unknown),
+ (#"{"Stream":[{"streamType":1},{"streamType":2,"selected":true}]}"#, .directPlay),
+ (#"{"Stream":[{"streamType":2,"decision":""}]}"#, .directPlay),
+ (#"{"Stream":[{"streamType":1,"decision":"copy"}]}"#, .directStream),
+ (#"{"Stream":[{"streamType":2,"decision":"transcode"}]}"#, .transcoding),
+ (#"{"Stream":[{"streamType":2,"decision":"future-value"}]}"#, .unknown),
+ (#"{"decision":"transcode","Stream":[{"streamType":2}]}"#, .unknown)
+ ])
+ func classifiesOnlyEstablishedDecisions(part: String, expected: PlexSessionDeliveryMethod) throws {
+ #expect(try decodeSession(part: part).deliveryMethod == expected)
+ }
+
+ @Test func ignoresUnselectedAlternativesAndInactiveSubtitles() throws {
+ let session = try decodeSession(part: #"""
+ {"decision":"transcode","Stream":[
+ {"streamType":1,"decision":"copy"},
+ {"streamType":2,"selected":false,"decision":"transcode"},
+ {"streamType":2,"selected":true,"decision":"copy"},
+ {"streamType":3,"selected":false,"decision":"burn"},
+ {"streamType":3,"decision":"ignore"}
+ ]}
+ """#)
+ #expect(session.deliveryMethod == .directStream)
+ }
+
+ @Test func selectionFlagsDecodeAndChooseActiveMediaAndPart() throws {
+ let json = #"""
+ {"title":"Example","Player":{},"Media":[
+ {"selected":0,"Part":[{"decision":"directplay"}]},
+ {"selected":"1","Part":[
+ {"selected":false,"decision":"directplay"},
+ {"selected":1,"decision":"transcode","Stream":[
+ {"streamType":2,"selected":"1","decision":"transcode"}
+ ]}
+ ]}
+ ]}
+ """#
+ let session = try JSONDecoder().decode(PlexSession.self, from: Data(json.utf8))
+ #expect(session.media?.last?.selected == true)
+ #expect(session.media?.last?.part?.last?.selected == true)
+ #expect(session.deliveryMethod == .transcoding)
+ }
+
+ @Test(arguments: [
+ #"[{"Part":[{"decision":"directplay"}]},{"Part":[{"decision":"transcode"}]}]"#,
+ #"[{"Part":[{"decision":"directplay"},{"decision":"transcode"}]}]"#,
+ #"[{"selected":true,"Part":[]},{"selected":true,"Part":[]}]"#,
+ #"[{"Part":[{"decision":"transcode","Stream":[{"streamType":2,"decision":"copy"},{"streamType":2,"decision":"transcode"}]}]}]"#
+ ])
+ func ambiguousSelectionIsUnknown(media: String) throws {
+ let json = "{\"title\":\"Example\",\"Player\":{},\"Media\":\(media)}"
+ let session = try JSONDecoder().decode(PlexSession.self, from: Data(json.utf8))
+ #expect(session.deliveryMethod == .unknown)
+ }
+
+ @Test func directPlayPartDoesNotRequireSourceTrackSelection() throws {
+ // Source tracks are not playback decisions; clients can choose among them locally.
+ let session = try decodeSession(part: #"""
+ {"decision":"directplay","Stream":[
+ {"streamType":1},
+ {"streamType":2}, {"streamType":2},
+ {"streamType":3,"decision":"copy"}, {"streamType":3,"decision":"copy"}
+ ]}
+ """#)
+ #expect(session.deliveryMethod == .directPlay)
+ }
+
+ @Test(arguments: [
+ (#"{"videoDecision":"transcode","audioDecision":"copy"}"#, PlexSessionDeliveryMethod.transcoding),
+ (#"{"videoDecision":"copy","audioDecision":"transcode"}"#, .transcoding),
+ (#"{"videoDecision":"copy","audioDecision":"copy"}"#, .directStream),
+ (#"{"audioDecision":"transcode"}"#, .transcoding),
+ (#"{"key":"/transcode/sessions/example"}"#, .unknown)
+ ])
+ func liveTVUsesTranscodeOutputDecisions(transcode: String, expected: PlexSessionDeliveryMethod) throws {
+ let json = """
+ {"title":"Live TV","live":true,"Player":{},
+ "Media":[{"Part":[{"decision":"directplay","Stream":[{"streamType":1}]}]}],
+ "TranscodeSession":\(transcode)}
+ """
+ let session = try JSONDecoder().decode(PlexSession.self, from: Data(json.utf8))
+ #expect(session.deliveryMethod == expected)
+ }
+
+ @Test func playbackNotificationPreservesDecisionsOnlyForTheSameTranscode() throws {
+ let json = #"""
+ {"title":"Live TV","live":true,"Player":{},"TranscodeSession":{
+ "key":"/transcode/sessions/example","videoDecision":"copy","audioDecision":"transcode"
+ }}
+ """#
+ let session = try JSONDecoder().decode(PlexSession.self, from: Data(json.utf8))
+ for key in ["/transcode/sessions/example", "/transcode/sessions/replacement"] {
+ let updated = session.applying(playNotification: PlexPlaySessionStateNotification(
+ sessionKey: nil, state: "paused", viewOffset: 1234, ratingKey: nil, key: nil,
+ transcodeSessionKey: key, hasRatingKey: false, hasKey: false
+ ))
+ #expect(updated.deliveryMethod == (key == session.transcodeSessionKey ? .transcoding : .unknown))
+ }
+ }
+
+ @Test func classifiesCapturedPlexSessions() throws {
+ // Actual /status/sessions decision fields, captured September 12, 2026.
+ // Titles are anonymous; credentials, addresses and media identifiers are omitted.
+ let json = #"""
+ [
+ {"type":"movie","Media":[{"selected":true,"Part":[{"decision":"directplay","selected":true,"Stream":[{"streamType":1},{"selected":true,"streamType":2}]}]}],"title":"Session 1","Player":{}},
+ {"type":"episode","Media":[{"selected":true,"Part":[{"decision":"directplay","selected":true,"Stream":[{"streamType":1},{"selected":true,"streamType":2}]}]}],"title":"Session 2","Player":{}},
+ {"type":"episode","Media":[{"selected":true,"Part":[{"decision":"transcode","selected":true,"Stream":[{"streamType":1,"decision":"transcode"},{"selected":true,"streamType":2,"decision":"transcode"},{"selected":true,"streamType":3,"decision":"transcode"}]}]}],"TranscodeSession":{"videoDecision":"transcode","audioDecision":"transcode"},"title":"Session 3","Player":{}},
+ {"type":"episode","Media":[{"selected":true,"Part":[{"decision":"transcode","selected":true,"Stream":[{"streamType":1,"decision":"copy"},{"selected":true,"streamType":2,"decision":"transcode"}]}]}],"TranscodeSession":{"videoDecision":"copy","audioDecision":"transcode"},"title":"Session 4","Player":{}}
+ ]
+ """#
+ let sessions = try JSONDecoder().decode([PlexSession].self, from: Data(json.utf8))
+ let summary = PlexActivitySummary(sessions: sessions)
+ #expect(summary.streamCount == 4)
+ #expect(summary.directPlayCount == 2)
+ #expect(summary.transcodingCount == 2)
+ #expect(summary.directStreamCount == 0)
+ #expect(summary.unknownCount == 0)
+ }
+
+ @Test func missingAndInvalidBandwidthAreNotZeroReports() throws {
+ let summary = try PlexActivitySummary(sessions: [
+ decodeSession(bandwidth: 100, location: "lan"),
+ decodeSession(bandwidth: 200, location: "wan"),
+ decodeSession(bandwidth: 300, location: "future"),
+ decodeSession(bandwidth: 0, location: "lan"),
+ decodeSession(bandwidth: -1),
+ decodeSession()
+ ])
+ #expect(summary.streamCount == 6)
+ #expect(summary.reportedBandwidthCount == 4)
+ #expect(summary.totalBandwidthKbps == 600)
+ #expect(summary.unknownLocationBandwidthKbps == 300)
+ #expect(summary.unknownLocationCount == 1)
+ #expect(summary.hasPartialBandwidth)
+ #expect(summary.localBandwidthKbps + summary.remoteBandwidthKbps + summary.unknownLocationBandwidthKbps == summary.totalBandwidthKbps)
+ }
+
+ @Test func emptyAndUnavailableSummariesRemainDistinct() throws {
+ let empty = PlexActivitySummary(sessions: [])
+ let unavailable = try PlexActivitySummary(sessions: [decodeSession()])
+ #expect(empty.streamCount == 0)
+ #expect(unavailable.streamCount == 1)
+ #expect(unavailable.reportedBandwidthCount == 0)
+ #expect(!unavailable.hasPartialBandwidth)
+ }
+
+ @Test(arguments: [(44700.0, "44.7 Mbps"), (1000, "1 Mbps"), (0, "0 Mbps"), (1, "<0.1 Mbps"), (50, "0.1 Mbps")])
+ func formatsDecimalBandwidth(kbps: Double, expected: String) {
+ #expect(PlexActivitySummary.bandwidthText(kbps: kbps, locale: Locale(identifier: "en_US")) == expected)
+ }
+
+ @Test func formatsBandwidthForLocale() {
+ #expect(PlexActivitySummary.bandwidthText(kbps: 44700, locale: Locale(identifier: "de_DE")) == "44,7 Mbps")
+ }
+
+ private func decodeSession(part: String = #"{"decision":"directplay"}"#, bandwidth: Int? = nil, location: String? = nil, state: String = "playing") throws -> PlexSession {
+ var playback: [String: Any] = [:]
+ playback["bandwidth"] = bandwidth
+ playback["location"] = location
+ let sessionObject = try JSONSerialization.jsonObject(with: Data(part.utf8))
+ let json: [String: Any] = [
+ "title": "Example", "Player": ["state": state], "Session": playback,
+ "Media": [["Part": [sessionObject]]]
+ ]
+ return try JSONDecoder().decode(PlexSession.self, from: JSONSerialization.data(withJSONObject: json))
+ }
+}
diff --git a/PlexBarTests/PlexAppRuntimeTests.swift b/PlexBarTests/PlexAppRuntimeTests.swift
new file mode 100644
index 0000000..66cf57d
--- /dev/null
+++ b/PlexBarTests/PlexAppRuntimeTests.swift
@@ -0,0 +1,51 @@
+import PlexMockData
+import Foundation
+import Testing
+@testable import PlexBar
+
+@MainActor
+@Test func defaultsToLiveRuntimeMode() {
+ #expect(PlexAppRuntime.mode(arguments: ["PlexBar"]) == .live)
+ #expect(PlexAppRuntime.makeImageSession(arguments: ["PlexBar"]) === URLSession.shared)
+}
+
+#if DEBUG
+@MainActor
+@Test func selectsMockRuntimeModeFromArgument() {
+ #expect(PlexAppRuntime.mode(arguments: ["PlexBar", "--mock"]) == .mock)
+}
+
+@MainActor
+@Test func mockAvatarsLoadAtRequestedSizesWithoutSeededCache() async throws {
+ let session = PlexAppRuntime.makeImageSession(arguments: ["PlexBar", "--mock"])
+ let payload = try PlexMockServerPayload.loadDefault()
+ let server = PlexDebugMockServer.mockServer
+ let serverURL = try #require(server.connections.first?.uri)
+ let imageClient = PlexImageClient(
+ session: session,
+ cache: PlexImageMemoryCache(),
+ requestCoordinator: PlexImageRequestCoordinator()
+ )
+
+ for user in payload.users {
+ let request = try #require(PlexAvatarView.resolveRequest(
+ thumb: user.avatar,
+ serverURL: serverURL,
+ serverToken: server.accessToken,
+ userToken: PlexDebugMockServer.mockUserToken
+ ))
+ for maximumPixelSize in [60, 120] {
+ #expect(imageClient.cachedCGImageResult(
+ from: [request.url], token: request.token, maximumPixelSize: maximumPixelSize
+ ) == nil)
+ let result = try #require(await imageClient.fetchCGImageResult(
+ from: [request.url],
+ token: request.token,
+ clientContext: PlexClientContext(clientIdentifier: "mock-avatar-tests"),
+ maximumPixelSize: maximumPixelSize
+ ))
+ #expect(max(result.image.width, result.image.height) == maximumPixelSize)
+ }
+ }
+}
+#endif
diff --git a/PlexBarTests/PlexArtworkPaletteTests.swift b/PlexBarTests/PlexArtworkPaletteTests.swift
new file mode 100644
index 0000000..41b9277
--- /dev/null
+++ b/PlexBarTests/PlexArtworkPaletteTests.swift
@@ -0,0 +1,312 @@
+import CoreGraphics
+import Foundation
+import Testing
+@testable import PlexBar
+
+@Test func artworkPaletteExtractorKeepsProminentPosterColors() async throws {
+ let image = try #require(testImage(quadrants: [
+ PlexPaletteColor(red: 0.92, green: 0.18, blue: 0.16),
+ PlexPaletteColor(red: 0.18, green: 0.32, blue: 0.88),
+ PlexPaletteColor(red: 0.95, green: 0.68, blue: 0.14),
+ PlexPaletteColor(red: 0.12, green: 0.72, blue: 0.42),
+ ]))
+
+ let palette = try #require(PlexArtworkPaletteExtractor().extract(from: image))
+
+ #expect(palette.colors.count == 4)
+ #expect(palette.colors.contains { $0.red > 0.30 && $0.saturation > 0.45 })
+ #expect(palette.colors.contains { $0.blue > 0.24 && $0.saturation > 0.45 })
+}
+
+@Test func artworkPaletteExtractorNormalizesColorsForReadableDarkMesh() async throws {
+ let image = try #require(testImage(quadrants: [
+ PlexPaletteColor(red: 0.98, green: 0.92, blue: 0.18),
+ PlexPaletteColor(red: 0.88, green: 0.24, blue: 0.22),
+ PlexPaletteColor(red: 0.24, green: 0.90, blue: 0.54),
+ PlexPaletteColor(red: 0.25, green: 0.42, blue: 0.98),
+ ]))
+
+ let palette = try #require(PlexArtworkPaletteExtractor().extract(from: image))
+
+ for color in palette.colors {
+ #expect(color.brightness <= 0.42)
+ #expect(color.brightness >= 0.18)
+ #expect(color.saturation >= 0.24)
+ }
+}
+
+@Test func artworkPaletteExtractorKeepsGrayscaleArtworkNeutral() async throws {
+ let image = try #require(testImage(quadrants: [
+ PlexPaletteColor(red: 0.80, green: 0.80, blue: 0.80),
+ PlexPaletteColor(red: 0.60, green: 0.60, blue: 0.60),
+ PlexPaletteColor(red: 0.35, green: 0.35, blue: 0.35),
+ PlexPaletteColor(red: 0.22, green: 0.22, blue: 0.22),
+ ]))
+
+ let palette = try #require(PlexArtworkPaletteExtractor().extract(from: image))
+
+ for color in palette.colors {
+ #expect(abs(color.red - color.green) < 0.0001)
+ #expect(abs(color.green - color.blue) < 0.0001)
+ }
+}
+
+@Test func imageClientCachesPaletteByTokenizedURLKey() async throws {
+ let client = PlexImageClient()
+ let url = try #require(URL(string: "https://example.com/library/metadata/777/thumb"))
+ let palette = PlexArtworkPalette(
+ colors: [
+ PlexPaletteColor(red: 0.2, green: 0.1, blue: 0.1),
+ PlexPaletteColor(red: 0.1, green: 0.2, blue: 0.1),
+ PlexPaletteColor(red: 0.1, green: 0.1, blue: 0.2),
+ PlexPaletteColor(red: 0.2, green: 0.2, blue: 0.1),
+ ]
+ )
+
+ client.cachePalette(palette, for: url, token: "token-777")
+
+ #expect(client.cachedPalette(for: url, token: "token-777") == palette)
+ #expect(client.cachedPalette(for: url, token: "different-token") == nil)
+}
+
+@Test func imageMemoryCacheStrictlyEvictsLeastRecentlyUsedImageByByteCost() async throws {
+ let firstImage = try #require(testImage(
+ width: 40,
+ quadrants: repeatedColor(PlexPaletteColor(red: 0.72, green: 0.18, blue: 0.14))
+ ))
+ let secondImage = try #require(testImage(
+ width: 40,
+ quadrants: repeatedColor(PlexPaletteColor(red: 0.12, green: 0.32, blue: 0.82))
+ ))
+ let singleImageCost = firstImage.bytesPerRow * firstImage.height
+ let cache = PlexImageMemoryCache(
+ imageCountLimit: 4,
+ imageCostLimit: singleImageCost,
+ paletteCountLimit: 4
+ )
+
+ cache.insert(firstImage, for: "first")
+ cache.insert(secondImage, for: "second")
+
+ #expect(cache.cgImage(for: "first") == nil)
+ #expect(cache.cgImage(for: "second") != nil)
+}
+
+@Test func imageMemoryCacheStrictlyBoundsPaletteCount() async throws {
+ let cache = PlexImageMemoryCache(
+ imageCountLimit: 1,
+ imageCostLimit: 1,
+ paletteCountLimit: 1
+ )
+ let first = PlexArtworkPalette(colors: repeatedColor(
+ PlexPaletteColor(red: 0.18, green: 0.28, blue: 0.38)
+ ))
+ let second = PlexArtworkPalette(colors: repeatedColor(
+ PlexPaletteColor(red: 0.48, green: 0.38, blue: 0.28)
+ ))
+
+ cache.insert(first, for: "first")
+ cache.insert(second, for: "second")
+
+ #expect(cache.palette(for: "first") == nil)
+ #expect(cache.palette(for: "second") == second)
+}
+
+@Test func imageMemoryCacheRefreshesLeastRecentlyUsedOrderOnRead() async throws {
+ let firstImage = try #require(testImage(
+ width: 20,
+ quadrants: repeatedColor(PlexPaletteColor(red: 0.72, green: 0.18, blue: 0.14))
+ ))
+ let secondImage = try #require(testImage(
+ width: 20,
+ quadrants: repeatedColor(PlexPaletteColor(red: 0.12, green: 0.32, blue: 0.82))
+ ))
+ let thirdImage = try #require(testImage(
+ width: 20,
+ quadrants: repeatedColor(PlexPaletteColor(red: 0.18, green: 0.72, blue: 0.32))
+ ))
+ let totalCost = (firstImage.bytesPerRow * firstImage.height) * 3
+ let cache = PlexImageMemoryCache(
+ imageCountLimit: 2,
+ imageCostLimit: totalCost,
+ paletteCountLimit: 1
+ )
+
+ cache.insert(firstImage, for: "first")
+ cache.insert(secondImage, for: "second")
+ #expect(cache.cgImage(for: "first") != nil)
+ cache.insert(thirdImage, for: "third")
+
+ #expect(cache.cgImage(for: "first") != nil)
+ #expect(cache.cgImage(for: "second") == nil)
+ #expect(cache.cgImage(for: "third") != nil)
+}
+
+@MainActor
+@Test func artworkPresentationStateHydratesCachedArtworkSynchronously() async throws {
+ let client = PlexImageClient()
+ let url = try #require(URL(string: "https://example.com/library/metadata/888/thumb"))
+ let image = try #require(testImage(quadrants: [
+ PlexPaletteColor(red: 0.78, green: 0.16, blue: 0.14),
+ PlexPaletteColor(red: 0.18, green: 0.28, blue: 0.82),
+ PlexPaletteColor(red: 0.86, green: 0.68, blue: 0.18),
+ PlexPaletteColor(red: 0.14, green: 0.64, blue: 0.40),
+ ]))
+
+ let palette = try #require(PlexArtworkPaletteExtractor().extract(from: image))
+ client.cachePalette(palette, for: url, token: "token-888")
+ let cache = PlexImageMemoryCache.shared
+ cache.insert(image, for: "\(url.absoluteString)|token-888")
+
+ let state = PlexArtworkPresentationState(
+ primaryImageURL: url,
+ token: "token-888",
+ wantsPalette: true,
+ imageClient: client
+ )
+
+ #expect(state.cgImage != nil)
+ #expect(state.palette == palette)
+ #expect(state.isLoading == false)
+}
+
+@MainActor
+@Test func artworkPresentationStateDoesNotPublishSupersededPalette() async throws {
+ let client = PlexImageClient()
+ let firstURL = try #require(URL(string: "https://example.com/library/metadata/991/thumb"))
+ let secondURL = try #require(URL(string: "https://example.com/library/metadata/992/thumb"))
+ let firstImage = try #require(testImage(
+ width: 40,
+ quadrants: repeatedColor(PlexPaletteColor(red: 0.86, green: 0.16, blue: 0.12))
+ ))
+ let secondImage = try #require(testImage(
+ width: 44,
+ quadrants: repeatedColor(PlexPaletteColor(red: 0.12, green: 0.24, blue: 0.88))
+ ))
+ let firstPalette = try #require(PlexArtworkPaletteExtractor().extract(from: firstImage))
+ let secondPalette = try #require(PlexArtworkPaletteExtractor().extract(from: secondImage))
+ let cache = PlexImageMemoryCache.shared
+ cache.insert(firstImage, for: firstURL.absoluteString)
+ cache.insert(secondImage, for: secondURL.absoluteString)
+
+ let gate = PaletteExtractionGate(
+ delayedImageWidth: firstImage.width,
+ delayedPalette: firstPalette,
+ immediatePalette: secondPalette
+ )
+ let state = PlexArtworkPresentationState(
+ imageClient: client,
+ extractPalette: gate.extract
+ )
+ let context = PlexClientContext(clientIdentifier: "palette-supersession-test")
+
+ let firstLoad = Task {
+ await state.load(
+ primaryImageURL: firstURL,
+ fallbackImageURL: nil,
+ token: "",
+ clientContext: context,
+ wantsPalette: true
+ )
+ }
+ await Task.detached {
+ gate.waitUntilDelayedExtractionStarts()
+ }.value
+
+ await state.load(
+ primaryImageURL: secondURL,
+ fallbackImageURL: nil,
+ token: "",
+ clientContext: context,
+ wantsPalette: true
+ )
+ #expect(state.palette == secondPalette)
+
+ gate.finishDelayedExtraction()
+ await firstLoad.value
+
+ #expect(state.palette == secondPalette)
+ #expect(state.cgImage?.width == secondImage.width)
+ #expect(state.isLoading == false)
+}
+
+private final class PaletteExtractionGate: @unchecked Sendable {
+ private let delayedImageWidth: Int
+ private let delayedPalette: PlexArtworkPalette
+ private let immediatePalette: PlexArtworkPalette
+ private let delayedExtractionStarted = DispatchSemaphore(value: 0)
+ private let allowDelayedExtractionToFinish = DispatchSemaphore(value: 0)
+
+ init(
+ delayedImageWidth: Int,
+ delayedPalette: PlexArtworkPalette,
+ immediatePalette: PlexArtworkPalette
+ ) {
+ self.delayedImageWidth = delayedImageWidth
+ self.delayedPalette = delayedPalette
+ self.immediatePalette = immediatePalette
+ }
+
+ func extract(from image: CGImage) -> PlexArtworkPalette? {
+ guard image.width == delayedImageWidth else {
+ return immediatePalette
+ }
+
+ delayedExtractionStarted.signal()
+ allowDelayedExtractionToFinish.wait()
+ return delayedPalette
+ }
+
+ func waitUntilDelayedExtractionStarts() {
+ delayedExtractionStarted.wait()
+ }
+
+ func finishDelayedExtraction() {
+ allowDelayedExtractionToFinish.signal()
+ }
+}
+
+private func repeatedColor(_ color: PlexPaletteColor) -> [PlexPaletteColor] {
+ Array(repeating: color, count: 4)
+}
+
+private func testImage(width: Int = 40, quadrants: [PlexPaletteColor]) -> CGImage? {
+ guard quadrants.count == 4 else {
+ return nil
+ }
+
+ let height = width
+ let bytesPerPixel = 4
+ let bytesPerRow = width * bytesPerPixel
+ let bitsPerComponent = 8
+ let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
+
+ var buffer = [UInt8](repeating: 0, count: width * height * bytesPerPixel)
+
+ guard let context = CGContext(
+ data: &buffer,
+ width: width,
+ height: height,
+ bitsPerComponent: bitsPerComponent,
+ bytesPerRow: bytesPerRow,
+ space: colorSpace,
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ ) else {
+ return nil
+ }
+
+ let halfWidth = CGFloat(width) / 2
+ let rects = [
+ CGRect(x: 0, y: halfWidth, width: halfWidth, height: halfWidth),
+ CGRect(x: halfWidth, y: halfWidth, width: halfWidth, height: halfWidth),
+ CGRect(x: 0, y: 0, width: halfWidth, height: halfWidth),
+ CGRect(x: halfWidth, y: 0, width: halfWidth, height: halfWidth),
+ ]
+
+ for (color, rect) in zip(quadrants, rects) {
+ context.setFillColor(red: color.red, green: color.green, blue: color.blue, alpha: 1)
+ context.fill(rect)
+ }
+
+ return context.makeImage()
+}
diff --git a/PlexBarTests/PlexAudioPlaybackPresentationTests.swift b/PlexBarTests/PlexAudioPlaybackPresentationTests.swift
new file mode 100644
index 0000000..feabcdb
--- /dev/null
+++ b/PlexBarTests/PlexAudioPlaybackPresentationTests.swift
@@ -0,0 +1,110 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+struct PlexAudioPlaybackPresentationTests {
+ @Test func selectedAudioOnlySourceUsesTrackHierarchyAndCoverArtwork() throws {
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "track-9",
+ "title": "Chapter 3",
+ "type": "track",
+ "grandparentTitle": "Anthony Bourdain",
+ "parentTitle": "Kitchen Confidential",
+ "thumb": "/tracks/9/thumb",
+ "parentThumb": "/albums/4/thumb",
+ "grandparentThumb": "/artists/2/thumb",
+ "art": "/artists/2/art",
+ "Media": [{
+ "audioCodec": "aac",
+ "Part": [{"id": "50"}]
+ }]
+ }
+ """#)
+
+ let presentation = try #require(PlexAudioPlaybackPresentation(
+ item: item,
+ source: PlexPlaybackSource(mediaIndex: 0, partIndex: 0)
+ ))
+
+ #expect(presentation.title == "Chapter 3")
+ #expect(presentation.metadataLines == [
+ "Anthony Bourdain",
+ "Kitchen Confidential",
+ ])
+ #expect(presentation.artworkPaths == [
+ "/albums/4/thumb",
+ "/artists/2/thumb",
+ "/tracks/9/thumb",
+ "/artists/2/art",
+ ])
+ }
+
+ @Test func selectedSourceFactsDetermineAudioPresentationWithoutTypeGuessing() throws {
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "mixed-1",
+ "title": "Selected Source",
+ "type": "track",
+ "Media": [
+ {"videoCodec": "h264", "audioCodec": "aac", "Part": [{"id": "1"}]},
+ {"audioCodec": "flac", "Part": [{"id": "2"}]}
+ ]
+ }
+ """#)
+
+ #expect(PlexAudioPlaybackPresentation(
+ item: item,
+ source: PlexPlaybackSource(mediaIndex: 0, partIndex: 0)
+ ) == nil)
+ #expect(PlexAudioPlaybackPresentation(
+ item: item,
+ source: PlexPlaybackSource(mediaIndex: 1, partIndex: 0)
+ ) != nil)
+ }
+
+ @Test func absentOrIncompleteSelectedSourceFactsRemainVideoPresentation() throws {
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "unknown-1",
+ "title": "Unknown Source",
+ "type": "track",
+ "Media": [{"Part": [{"id": "1"}]}]
+ }
+ """#)
+
+ #expect(PlexAudioPlaybackPresentation(
+ item: item,
+ source: PlexPlaybackSource(mediaIndex: 0, partIndex: 0)
+ ) == nil)
+ #expect(PlexAudioPlaybackPresentation(
+ item: item,
+ source: PlexPlaybackSource(mediaIndex: 9, partIndex: 0)
+ ) == nil)
+ }
+
+ @Test func repeatedHierarchyLabelsAreNotRenderedTwice() throws {
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "book-1",
+ "title": "The Left Hand of Darkness",
+ "type": "track",
+ "grandparentTitle": "Ursula K. Le Guin",
+ "parentTitle": "The Left Hand of Darkness",
+ "Media": [{"audioCodec": "aac", "Part": [{"id": "1"}]}]
+ }
+ """#)
+
+ let presentation = try #require(PlexAudioPlaybackPresentation(
+ item: item,
+ source: PlexPlaybackSource(mediaIndex: 0, partIndex: 0)
+ ))
+
+ #expect(presentation.metadataLines == ["Ursula K. Le Guin"])
+ }
+
+ private func decodeItem(_ json: String) throws -> PlexMediaItem {
+ try JSONDecoder().decode(PlexMediaItem.self, from: Data(json.utf8))
+ }
+}
diff --git a/PlexBarTests/PlexAudioPlayerStageHostTests.swift b/PlexBarTests/PlexAudioPlayerStageHostTests.swift
new file mode 100644
index 0000000..0bb930a
--- /dev/null
+++ b/PlexBarTests/PlexAudioPlayerStageHostTests.swift
@@ -0,0 +1,85 @@
+import PlexModels
+import AppKit
+import Foundation
+import Testing
+@testable import PlexBar
+
+@MainActor
+struct PlexAudioPlayerStageHostTests {
+ @Test func videoPresentationDoesNotInstallAudioStage() {
+ let overlayView = NSView()
+ let host = PlexAudioPlayerStageHost()
+
+ host.update(in: overlayView, overlay: nil)
+
+ #expect(host.hostingView == nil)
+ #expect(overlayView.subviews.isEmpty)
+ }
+
+ @Test func audioStageFillsOverlayWithoutContributingContentSizing() throws {
+ let overlayView = NSView()
+ let host = PlexAudioPlayerStageHost()
+
+ host.update(in: overlayView, overlay: try audioOverlay())
+
+ let hostingView = try #require(host.hostingView)
+ #expect(hostingView.superview === overlayView)
+ #expect(hostingView.sizingOptions.isEmpty)
+ #expect(hostingView.translatesAutoresizingMaskIntoConstraints == false)
+ #expect(overlayView.constraints.count == 4)
+ }
+
+ @Test func returningToVideoRemovesAudioStageFromLayout() throws {
+ let overlayView = NSView()
+ let host = PlexAudioPlayerStageHost()
+
+ host.update(in: overlayView, overlay: try audioOverlay())
+ let hostingView = try #require(host.hostingView)
+
+ host.update(in: overlayView, overlay: nil)
+
+ #expect(host.hostingView == nil)
+ #expect(hostingView.superview == nil)
+ #expect(overlayView.subviews.isEmpty)
+ }
+
+ @Test func audioStageMovesToReplacementAVKitOverlay() throws {
+ let firstOverlayView = NSView()
+ let replacementOverlayView = NSView()
+ let host = PlexAudioPlayerStageHost()
+ let overlay = try audioOverlay()
+
+ host.update(in: firstOverlayView, overlay: overlay)
+ let hostingView = try #require(host.hostingView)
+ host.update(in: replacementOverlayView, overlay: overlay)
+
+ #expect(host.hostingView === hostingView)
+ #expect(hostingView.superview === replacementOverlayView)
+ #expect(firstOverlayView.subviews.isEmpty)
+ #expect(replacementOverlayView.subviews == [hostingView])
+ }
+
+ private func audioOverlay() throws -> PlexAudioPlayerOverlay {
+ let item = try JSONDecoder().decode(PlexMediaItem.self, from: Data(#"""
+ {
+ "ratingKey": "track-9",
+ "title": "Chapter 3",
+ "type": "track",
+ "parentTitle": "Kitchen Confidential",
+ "parentThumb": "/library/metadata/9/thumb",
+ "Media": [{"audioCodec": "aac", "Part": [{"id": "50"}]}]
+ }
+ """#.utf8))
+ let presentation = try #require(PlexAudioPlaybackPresentation(
+ item: item,
+ source: PlexPlaybackSource(mediaIndex: 0, partIndex: 0)
+ ))
+
+ return PlexAudioPlayerOverlay(
+ presentation: presentation,
+ serverURL: URL(string: "https://plex.example"),
+ token: "token",
+ clientContext: PlexClientContext(clientIdentifier: "player-stage-test")
+ )
+ }
+}
diff --git a/PlexBarTests/PlexAuthRecoveryTests.swift b/PlexBarTests/PlexAuthRecoveryTests.swift
new file mode 100644
index 0000000..246fff4
--- /dev/null
+++ b/PlexBarTests/PlexAuthRecoveryTests.swift
@@ -0,0 +1,216 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+@MainActor
+struct PlexAuthRecoveryTests {
+ @Test func rejectedUnexpiredJWTIsRefreshedBeforeServerDiscoveryRetries() async throws {
+ let now = Date()
+ let rejectedToken = try accountJWT(
+ expiration: now.addingTimeInterval(3 * 24 * 60 * 60),
+ signature: "rejected"
+ )
+ let refreshedToken = try accountJWT(
+ expiration: now.addingTimeInterval(7 * 24 * 60 * 60),
+ signature: "refreshed"
+ )
+ let requestCounter = AuthRecoveryRequestCounter()
+ let session = makeAuthRecoverySession { request in
+ let url = try #require(request.url)
+
+ switch url.path {
+ case "/api/v2/resources":
+ let attempt = requestCounter.incrementAndReturn()
+ let token = request.value(forHTTPHeaderField: "X-Plex-Token")
+ if attempt == 1 {
+ #expect(token == rejectedToken)
+ return try authRecoveryResponse(url: url, statusCode: 401, data: Data())
+ }
+
+ #expect(token == refreshedToken)
+ let data = try #require(#"""
+ [
+ {
+ "name": "Test Server",
+ "clientIdentifier": "server-id",
+ "provides": "server",
+ "accessToken": "server-token",
+ "connections": [
+ { "uri": "https://plex.test:32400", "local": false, "relay": false }
+ ]
+ }
+ ]
+ """#.data(using: .utf8))
+ return try authRecoveryResponse(url: url, statusCode: 200, data: data)
+
+ case "/api/v2/devices":
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Token") == refreshedToken)
+ let data = try #require(#"""
+ [
+ {
+ "name": "Test Server",
+ "clientIdentifier": "server-id",
+ "provides": "server",
+ "token": "server-token",
+ "connections": [
+ { "uri": "https://plex.test:32400" }
+ ]
+ }
+ ]
+ """#.data(using: .utf8))
+ return try authRecoveryResponse(url: url, statusCode: 200, data: data)
+
+ case "/api/v2/auth/nonce":
+ let data = try #require(#"{"nonce":"test-nonce"}"#.data(using: .utf8))
+ return try authRecoveryResponse(url: url, statusCode: 200, data: data)
+
+ case "/api/v2/auth/token":
+ let data = try JSONEncoder().encode(["auth_token": refreshedToken])
+ return try authRecoveryResponse(url: url, statusCode: 200, data: data)
+
+ default:
+ Issue.record("Unexpected request: \(request)")
+ throw URLError(.unsupportedURL)
+ }
+ }
+
+ let suiteName = "PlexBarTests.PlexAuthRecovery.\(UUID().uuidString)"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ let credentials = PlexStoredCredentials(userToken: rejectedToken, serverToken: "server-token")
+ let settings = PlexSettingsStore(
+ defaults: defaults,
+ credentialStore: PlexMemoryCredentialStore(credentials: credentials),
+ initialCredentials: credentials
+ )
+ let connectionStore = PlexConnectionStore(settings: settings)
+ let apiClient = PlexAPIClient(session: session)
+ let libraryStore = PlexLibraryStore(connectionStore: connectionStore, client: apiClient)
+ let sessionStore = PlexSessionStore(connectionStore: connectionStore, client: apiClient)
+ let historyStore = PlexHistoryStore(
+ connectionStore: connectionStore,
+ libraryStore: libraryStore,
+ client: apiClient
+ )
+ let selectedServer = PlexServerResource(
+ id: "server-id",
+ name: "Test Server",
+ productVersion: nil,
+ accessToken: "server-token",
+ connections: [
+ PlexServerConnection(
+ uri: URL(string: "https://plex.test:32400")!,
+ local: false,
+ relay: false
+ )
+ ]
+ )
+ settings.saveServerSelection(selectedServer)
+ let identity = try PlexDeviceSigningIdentity.generate(keyID: "device-key")
+ let authClient = PlexAuthClient(session: session)
+ let tokenManager = PlexAccountJWTManager(
+ storage: settings,
+ client: authClient,
+ deviceIdentityStore: PlexMemoryDeviceIdentityStore(identity: identity)
+ )
+ let authStore = PlexAuthStore(
+ settings: settings,
+ connectionStore: connectionStore,
+ sessionStore: sessionStore,
+ historyStore: historyStore,
+ libraryStore: libraryStore,
+ client: authClient,
+ deviceIdentityStore: PlexMemoryDeviceIdentityStore(identity: identity),
+ accountJWTManager: tokenManager
+ )
+
+ await authStore.refreshServers()
+
+ #expect(authStore.errorMessage == nil)
+ #expect(authStore.availableServers.map(\.id) == ["server-id"])
+ #expect(settings.userToken == refreshedToken)
+ #expect(requestCounter.value == 2)
+ }
+
+ private func accountJWT(expiration: Date, signature: String) throws -> String {
+ let header = try JSONSerialization.data(withJSONObject: ["alg": "EdDSA"])
+ let payload = try JSONSerialization.data(withJSONObject: [
+ "exp": Int(expiration.timeIntervalSince1970)
+ ])
+ return "\(base64URL(header)).\(base64URL(payload)).\(signature)"
+ }
+
+ private func base64URL(_ data: Data) -> String {
+ data.base64EncodedString()
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: "")
+ }
+}
+
+private func makeAuthRecoverySession(
+ handler: @escaping @Sendable (URLRequest) throws -> (HTTPURLResponse, Data)
+) -> URLSession {
+ AuthRecoveryURLProtocol.requestHandler = handler
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.protocolClasses = [AuthRecoveryURLProtocol.self]
+ return URLSession(configuration: configuration)
+}
+
+private func authRecoveryResponse(
+ url: URL,
+ statusCode: Int,
+ data: Data
+) throws -> (HTTPURLResponse, Data) {
+ let response = try #require(HTTPURLResponse(
+ url: url,
+ statusCode: statusCode,
+ httpVersion: nil,
+ headerFields: ["Content-Type": "application/json"]
+ ))
+ return (response, data)
+}
+
+private final class AuthRecoveryURLProtocol: URLProtocol, @unchecked Sendable {
+ nonisolated(unsafe) static var requestHandler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))?
+
+ override class func canInit(with request: URLRequest) -> Bool { true }
+
+ override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
+
+ override func startLoading() {
+ guard let handler = Self.requestHandler else {
+ client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
+ return
+ }
+
+ do {
+ let (response, data) = try handler(request)
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: data)
+ client?.urlProtocolDidFinishLoading(self)
+ } catch {
+ client?.urlProtocol(self, didFailWithError: error)
+ }
+ }
+
+ override func stopLoading() {}
+}
+
+private final class AuthRecoveryRequestCounter: @unchecked Sendable {
+ private let lock = NSLock()
+ private var count = 0
+
+ var value: Int {
+ lock.withLock { count }
+ }
+
+ func incrementAndReturn() -> Int {
+ lock.withLock {
+ count += 1
+ return count
+ }
+ }
+}
diff --git a/PlexBarTests/PlexAutomaticDownloadRuleTests.swift b/PlexBarTests/PlexAutomaticDownloadRuleTests.swift
new file mode 100644
index 0000000..49fcf8c
--- /dev/null
+++ b/PlexBarTests/PlexAutomaticDownloadRuleTests.swift
@@ -0,0 +1,73 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+@Suite(.serialized)
+struct PlexAutomaticDownloadRuleTests {
+ @Test func registryPersistsExactRulesAndRemovesOnlyTheRequestedRule() async throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ defer { try? FileManager.default.removeItem(at: root) }
+ let registry = PlexAutomaticDownloadRuleRegistry(rootURL: root)
+ let first = rule(id: UUID(), title: "Alpha")
+ let second = rule(id: UUID(), title: "Beta")
+
+ try await registry.save(second)
+ try await registry.save(first)
+ #expect(try await registry.rules() == [first, second])
+
+ let reloaded = PlexAutomaticDownloadRuleRegistry(rootURL: root)
+ #expect(try await reloaded.rules() == [first, second])
+ try await reloaded.remove(withID: first.id)
+ #expect(try await reloaded.rules() == [second])
+ }
+
+ @Test func episodePolicyNeverSelectsMoviesOrClips() throws {
+ let unwatchedEpisode = try item(type: "episode", watched: false)
+ let watchedEpisode = try item(type: "episode", watched: true)
+ let movie = try item(type: "movie", watched: false)
+ let trailer = try item(type: "clip", watched: false, subtype: "trailer")
+
+ #expect(PlexAutomaticDownloadPolicy.allEpisodes.includes(unwatchedEpisode))
+ #expect(PlexAutomaticDownloadPolicy.allEpisodes.includes(watchedEpisode))
+ #expect(PlexAutomaticDownloadPolicy.unwatchedEpisodes.includes(unwatchedEpisode))
+ #expect(!PlexAutomaticDownloadPolicy.unwatchedEpisodes.includes(watchedEpisode))
+ #expect(!PlexAutomaticDownloadPolicy.allEpisodes.includes(movie))
+ #expect(!PlexAutomaticDownloadPolicy.allEpisodes.includes(trailer))
+ }
+
+ private func rule(id: UUID, title: String) -> PlexAutomaticDownloadRule {
+ PlexAutomaticDownloadRule(
+ id: id,
+ accountID: 7,
+ serverIdentifier: "server",
+ libraryID: "2",
+ sourceRatingKey: title,
+ sourceChildrenPath: "/library/metadata/\(title)/children",
+ sourceType: "show",
+ title: title,
+ posterPath: nil,
+ policy: .unwatchedEpisodes,
+ keepsUpToDate: true,
+ removesWatchedDownloads: false,
+ createdAt: Date(timeIntervalSince1970: title == "Alpha" ? 1 : 2),
+ lastRefreshedAt: nil,
+ lastErrorMessage: nil
+ )
+ }
+
+ private func item(
+ type: String,
+ watched: Bool,
+ subtype: String? = nil
+ ) throws -> PlexMediaItem {
+ let subtypeJSON = subtype.map { #", "subtype": "\#($0)""# } ?? ""
+ return try JSONDecoder().decode(
+ PlexMediaItem.self,
+ from: Data(
+ #"{"ratingKey":"1","title":"Item","type":"\#(type)","viewCount":\#(watched ? 1 : 0),"Media":[]\#(subtypeJSON)}"#.utf8
+ )
+ )
+ }
+}
diff --git a/PlexBarTests/PlexAutoplayPreferencesTests.swift b/PlexBarTests/PlexAutoplayPreferencesTests.swift
new file mode 100644
index 0000000..25d1058
--- /dev/null
+++ b/PlexBarTests/PlexAutoplayPreferencesTests.swift
@@ -0,0 +1,196 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+@Suite("Plex Autoplay Preferences")
+struct PlexAutoplayPreferencesTests {
+ private let referenceDate = Date(timeIntervalSince1970: 10_000)
+
+ @Test func eligibleVideoUsesTheConfiguredCountdown() throws {
+ let item = try mediaItem(type: "episode", duration: 30 * 60 * 1_000)
+
+ #expect(resolve(
+ item: item,
+ preferences: preferences(countdown: .fifteenSeconds),
+ lastInteractionDate: referenceDate.addingTimeInterval(-60)
+ ) == .presentPostPlay(autoAdvanceAfterSeconds: 15))
+ }
+
+ @Test func disablingAutoplayKeepsTheNextVideoWaitingForAnExplicitAction() throws {
+ let item = try mediaItem(type: "episode", duration: 30 * 60 * 1_000)
+
+ #expect(resolve(
+ item: item,
+ preferences: preferences(isEnabled: false),
+ lastInteractionDate: referenceDate
+ ) == .presentPostPlay(autoAdvanceAfterSeconds: nil))
+ }
+
+ @Test func passoutProtectionRequiresBothProlongedInactivityAndALongVideo() throws {
+ let longEpisode = try mediaItem(type: "episode", duration: 21 * 60 * 1_000)
+ let shortEpisode = try mediaItem(type: "episode", duration: 19 * 60 * 1_000)
+ let preferences = preferences(passoutProtection: .twoHours)
+ let moreThanTwoHoursAgo = referenceDate.addingTimeInterval(-(2 * 60 * 60 + 1))
+ let exactlyTwoHoursAgo = referenceDate.addingTimeInterval(-(2 * 60 * 60))
+
+ #expect(resolve(
+ item: longEpisode,
+ preferences: preferences,
+ lastInteractionDate: moreThanTwoHoursAgo
+ ) == .presentPostPlay(autoAdvanceAfterSeconds: nil))
+ #expect(resolve(
+ item: longEpisode,
+ preferences: preferences,
+ lastInteractionDate: exactlyTwoHoursAgo
+ ) == .presentPostPlay(autoAdvanceAfterSeconds: 10))
+ #expect(resolve(
+ item: shortEpisode,
+ preferences: preferences,
+ lastInteractionDate: moreThanTwoHoursAgo
+ ) == .presentPostPlay(autoAdvanceAfterSeconds: 10))
+ }
+
+ @Test func documentedPostPlayExclusionsRemainContinuous() throws {
+ let shortVideo = try mediaItem(type: "episode", duration: 5 * 60 * 1_000)
+ let trailer = try mediaItem(
+ type: "clip",
+ subtype: "trailer",
+ duration: 12 * 60 * 1_000
+ )
+ let playlistVideo = try mediaItem(
+ type: "movie",
+ duration: 90 * 60 * 1_000,
+ playlistItemID: "901"
+ )
+ let preferences = preferences()
+
+ #expect(resolve(item: shortVideo, preferences: preferences) == .advanceNext)
+ #expect(resolve(item: trailer, preferences: preferences) == .advanceNext)
+ #expect(resolve(item: playlistVideo, preferences: preferences) == .advanceNext)
+ #expect(resolve(
+ item: try mediaItem(type: "track", duration: 4 * 60 * 1_000),
+ mediaKind: .music,
+ preferences: preferences
+ ) == .advanceNext)
+ }
+
+ @Test func immediateCountdownAndExplicitRepeatKeepNativeQueueSemantics() throws {
+ let item = try mediaItem(type: "episode", duration: 30 * 60 * 1_000)
+
+ #expect(resolve(
+ item: item,
+ preferences: preferences(countdown: .immediate)
+ ) == .advanceNext)
+ #expect(resolve(
+ repeatMode: .one,
+ item: item,
+ preferences: preferences()
+ ) == .replayCurrent)
+ #expect(PlexPlaybackCompletionAction.resolve(
+ repeatMode: .all,
+ canAdvance: false,
+ canResetQueue: true,
+ completedItem: item,
+ mediaKind: .video,
+ duration: 30 * 60,
+ autoplayPreferences: preferences(),
+ lastInteractionDate: referenceDate,
+ now: referenceDate
+ ) == .resetQueue)
+ }
+
+ @Test func cinemaPreplayAdvancesDirectlyEvenForALongNonTrailerClip() throws {
+ let preRoll = try mediaItem(type: "clip", duration: 12 * 60 * 1_000)
+
+ #expect(resolve(
+ item: preRoll,
+ preferences: preferences(isEnabled: false),
+ isCinemaPreplayItem: true
+ ) == .advanceNext)
+ #expect(resolve(
+ repeatMode: .one,
+ item: preRoll,
+ preferences: preferences(),
+ isCinemaPreplayItem: true
+ ) == .replayCurrent)
+ }
+
+ @Test func postPlayPresentationUsesNativeManualAndAutomaticModes() {
+ #expect(PlexPostPlayPresentationMode.resolve(
+ action: .presentPostPlay(autoAdvanceAfterSeconds: 15),
+ autoplayPreferences: preferences(countdown: .fifteenSeconds)
+ ) == .automatic(afterSeconds: 15))
+ #expect(PlexPostPlayPresentationMode.resolve(
+ action: .presentPostPlay(autoAdvanceAfterSeconds: nil),
+ autoplayPreferences: preferences(isEnabled: false)
+ ) == .manual)
+ }
+
+ @Test func passoutConfirmationRemainsDistinctFromOrdinaryPostPlay() {
+ #expect(PlexPostPlayPresentationMode.resolve(
+ action: .presentPostPlay(autoAdvanceAfterSeconds: nil),
+ autoplayPreferences: preferences(isEnabled: true)
+ ) == .inactivityConfirmation)
+ #expect(PlexPostPlayPresentationMode.resolve(
+ action: .advanceNext,
+ autoplayPreferences: preferences()
+ ) == .none)
+ }
+
+ private func resolve(
+ repeatMode: PlexPlaybackRepeatMode = .off,
+ item: PlexMediaItem,
+ mediaKind: PlexPlaybackMediaKind = .video,
+ preferences: PlexAutoplayPreferences,
+ lastInteractionDate: Date? = nil,
+ isCinemaPreplayItem: Bool = false
+ ) -> PlexPlaybackCompletionAction {
+ PlexPlaybackCompletionAction.resolve(
+ repeatMode: repeatMode,
+ canAdvance: true,
+ canResetQueue: true,
+ completedItem: item,
+ mediaKind: mediaKind,
+ duration: item.duration.map { TimeInterval($0) / 1_000 },
+ autoplayPreferences: preferences,
+ lastInteractionDate: lastInteractionDate ?? referenceDate,
+ isCinemaPreplayItem: isCinemaPreplayItem,
+ now: referenceDate
+ )
+ }
+
+ private func preferences(
+ isEnabled: Bool = true,
+ countdown: PlexAutoplayCountdown = .tenSeconds,
+ passoutProtection: PlexPassoutProtection = .twoHours
+ ) -> PlexAutoplayPreferences {
+ PlexAutoplayPreferences(
+ isEnabled: isEnabled,
+ countdown: countdown,
+ passoutProtection: passoutProtection
+ )
+ }
+
+ private func mediaItem(
+ type: String,
+ subtype: String? = nil,
+ duration: Int,
+ playlistItemID: String? = nil
+ ) throws -> PlexMediaItem {
+ var fields: [String: Any] = [
+ "ratingKey": "42",
+ "key": "/library/metadata/42",
+ "type": type,
+ "title": "Test Item",
+ "duration": duration,
+ "Media": [],
+ ]
+ fields["subtype"] = subtype
+ fields["playlistItemID"] = playlistItemID
+ return try JSONDecoder().decode(
+ PlexMediaItem.self,
+ from: JSONSerialization.data(withJSONObject: fields)
+ )
+ }
+}
diff --git a/PlexBarTests/PlexBoundedConcurrentMapTests.swift b/PlexBarTests/PlexBoundedConcurrentMapTests.swift
new file mode 100644
index 0000000..3246882
--- /dev/null
+++ b/PlexBarTests/PlexBoundedConcurrentMapTests.swift
@@ -0,0 +1,53 @@
+import Foundation
+import Testing
+@testable import PlexBar
+
+@Suite
+struct PlexBoundedConcurrentMapTests {
+ @Test func capsConcurrencyAndPreservesInputOrder() async {
+ let probe = ConcurrentWorkProbe()
+
+ let results = await PlexBoundedConcurrentMap.compactMap(
+ Array(0..<12),
+ maximumConcurrentTasks: 3
+ ) { value in
+ await probe.process(value)
+ }
+
+ #expect(results == [2, 4, 8, 10, 14, 16, 20, 22])
+ #expect(await probe.maximumActiveTaskCount == 3)
+ }
+
+ @Test func normalizesAZeroLimitAndHandlesEmptyInput() async {
+ let probe = ConcurrentWorkProbe()
+ let results = await PlexBoundedConcurrentMap.compactMap(
+ [1, 2, 3],
+ maximumConcurrentTasks: 0
+ ) { value in
+ await probe.process(value)
+ }
+ let empty: [Int] = await PlexBoundedConcurrentMap.compactMap(
+ [],
+ maximumConcurrentTasks: 4
+ ) { value in
+ value
+ }
+
+ #expect(results == [2, 4])
+ #expect(await probe.maximumActiveTaskCount == 1)
+ #expect(empty.isEmpty)
+ }
+}
+
+private actor ConcurrentWorkProbe {
+ private var activeTaskCount = 0
+ private(set) var maximumActiveTaskCount = 0
+
+ func process(_ value: Int) async -> Int? {
+ activeTaskCount += 1
+ maximumActiveTaskCount = max(maximumActiveTaskCount, activeTaskCount)
+ try? await Task.sleep(for: .milliseconds(10))
+ activeTaskCount -= 1
+ return value.isMultiple(of: 3) ? nil : value * 2
+ }
+}
diff --git a/PlexBarTests/PlexCollectionPlaylistManagementTests.swift b/PlexBarTests/PlexCollectionPlaylistManagementTests.swift
new file mode 100644
index 0000000..dd20d2a
--- /dev/null
+++ b/PlexBarTests/PlexCollectionPlaylistManagementTests.swift
@@ -0,0 +1,573 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+@Suite(.serialized)
+@MainActor
+struct PlexCollectionPlaylistManagementTests {
+ @Test func collectionCreateRenameAndDeletePublishOnlyServerConfirmedState() async throws {
+ let scenario = CollectionManagementScenario(canManage: true)
+ let store = try makeStore { request in
+ try scenario.response(for: request)
+ }
+ let library = makeLibrary()
+
+ await store.loadLibraryProviderCapabilities()
+ #expect(store.supportsCollectionManagement)
+
+ _ = try await store.createCollection(named: "Silent Films", in: library)
+ let created = try #require(store.collections(in: library).first)
+ #expect(created.title == "Silent Films")
+
+ try await store.renameCollection(created, to: "Pre-Code", in: library)
+ let renamed = try #require(store.collections(in: library).first)
+ #expect(renamed.title == "Pre-Code")
+
+ try await store.deleteCollection(renamed, in: library)
+ #expect(store.collections(in: library).isEmpty)
+ #expect(scenario.methodsAndPaths == [
+ "GET /media/providers",
+ "POST /provider/collections",
+ "GET /library/sections/26/collections",
+ "PUT /provider/metadata/900",
+ "GET /library/sections/26/collections",
+ "DELETE /library/sections/26/collection/900",
+ ])
+ }
+
+ @Test func collectionManagementIsUnavailableWithoutTheManageFeature() async throws {
+ let scenario = CollectionManagementScenario(canManage: false)
+ let store = try makeStore { request in
+ try scenario.response(for: request)
+ }
+ let library = makeLibrary()
+
+ await store.loadLibraryProviderCapabilities()
+ #expect(!store.supportsCollectionManagement)
+
+ await #expect(throws: PlexAPIError.self) {
+ _ = try await store.createCollection(named: "No Access", in: library)
+ }
+ #expect(scenario.methodsAndPaths == [
+ "GET /media/providers",
+ ])
+ }
+
+ @Test func collectionManagementFailsClosedWithoutAnAdvertisedCollectionEndpoint() async throws {
+ let scenario = MissingCollectionFeatureScenario()
+ let store = try makeStore { request in
+ try scenario.response(for: request)
+ }
+ let library = makeLibrary()
+
+ await store.loadLibraryProviderCapabilities()
+ #expect(!store.supportsCollectionManagement)
+
+ await #expect(throws: PlexAPIError.self) {
+ _ = try await store.createCollection(named: "Unavailable", in: library)
+ }
+ #expect(scenario.methodsAndPaths == [
+ "GET /media/providers",
+ ])
+ }
+
+ @Test func createAndAddReportsTheCreatedCollectionWhenAddingFails() async throws {
+ let scenario = CollectionCreateAndAddFailureScenario()
+ let store = try makeStore { request in
+ try scenario.response(for: request)
+ }
+ let library = makeLibrary()
+ let movie = try decodeItem(
+ #"{"ratingKey":"42","key":"/library/metadata/42","type":"movie","title":"Charade"}"#
+ )
+
+ await store.loadLibraryProviderCapabilities()
+
+ do {
+ try await store.createCollection(
+ named: "Cary Grant",
+ containing: movie,
+ in: library
+ )
+ Issue.record("Expected adding the item to fail after the collection was created")
+ } catch let error as PlexBrowserMutationError {
+ #expect(
+ error.localizedDescription
+ == "Plex created Cary Grant, but the item was not added. You can add it to that collection after retrying the connection."
+ )
+ }
+
+ #expect(store.collections(in: library).map(\.title) == ["Cary Grant"])
+ #expect(scenario.methodsAndPaths == [
+ "GET /media/providers",
+ "POST /provider/collections",
+ "GET /library/sections/26/collections",
+ "PUT /provider/collections/900/items",
+ ])
+ }
+
+ @Test func confirmedCollectionAddDoesNotBecomeAFailedAddWhenRefreshFails() async throws {
+ let scenario = CollectionAddRefreshFailureScenario()
+ let store = try makeStore { request in
+ try scenario.response(for: request)
+ }
+ let library = makeLibrary()
+ let movie = try decodeItem(
+ #"{"ratingKey":"42","key":"/library/metadata/42","type":"movie","title":"Charade"}"#
+ )
+
+ await store.loadLibraryProviderCapabilities()
+ await store.loadCollections(in: library)
+ let collection = try #require(store.collections(in: library).first)
+
+ try await store.add(movie, to: collection, in: library)
+
+ #expect(scenario.addRequestCount == 1)
+ #expect(
+ store.collectionsErrorMessage(in: library)
+ == "Plex returned HTTP 500. Check the server URL and token."
+ )
+ }
+
+ @Test func playlistMovesAndRemovalUsePlaylistItemIDsThenReloadChildren() async throws {
+ let scenario = PlaylistChildrenManagementScenario()
+ let store = try makeStore { request in
+ try scenario.response(for: request)
+ }
+ let playlist = try decodeItem(
+ #"{"ratingKey":"901","key":"/playlists/901/items","type":"playlist","title":"Friday","smart":false,"readOnly":false}"#
+ )
+
+ await store.loadLibraryProviderCapabilities()
+ await store.loadChildren(of: playlist)
+ let middle = try #require(store.children(of: playlist).dropFirst().first)
+ #expect(store.canMoveChild(middle, in: playlist, direction: .down))
+
+ try await store.moveChild(middle, in: playlist, direction: .down)
+ #expect(store.children(of: playlist).map(\.playlistItemID) == ["1001", "1003", "1002"])
+
+ let moved = try #require(store.children(of: playlist).last)
+ try await store.removeChild(moved, from: playlist)
+ #expect(store.children(of: playlist).map(\.playlistItemID) == ["1001", "1003"])
+ #expect(scenario.methodsPathsAndQueries == [
+ "GET /media/providers",
+ "GET /playlists/901/items",
+ "PUT /provider/playlists/901/items/1002/move?source=library&after=1003",
+ "GET /playlists/901/items",
+ "DELETE /provider/playlists/901/items/1002?source=library",
+ "GET /playlists/901/items",
+ ])
+ }
+
+ @Test func smartAndReadOnlyPlaylistsNeverExposeItemManagement() async throws {
+ let store = try makeStore { request in
+ if request.url?.path == "/media/providers" {
+ return try managementResponse(for: request, data: providerData(canManage: true))
+ }
+ return try emptyResponse(for: request)
+ }
+ let smart = try decodeItem(
+ #"{"ratingKey":"1","key":"/playlists/1/items","type":"playlist","title":"Smart","smart":true,"readOnly":false}"#
+ )
+ let readOnly = try decodeItem(
+ #"{"ratingKey":"2","key":"/playlists/2/items","type":"playlist","title":"Shared","smart":false,"readOnly":true}"#
+ )
+
+ await store.loadLibraryProviderCapabilities()
+
+ #expect(store.supportsPlaylistManagement(for: smart))
+ #expect(!store.supportsChildManagement(of: smart))
+ #expect(!store.supportsPlaylistManagement(for: readOnly))
+ #expect(!store.supportsChildManagement(of: readOnly))
+ }
+
+ @Test func readOnlyProviderAllowsListingButRefusesPlaylistMutations() async throws {
+ let scenario = ReadOnlyPlaylistScenario()
+ let store = try makeStore { request in
+ try scenario.response(for: request)
+ }
+ let playlist = try decodeItem(
+ #"{"ratingKey":"901","key":"/playlists/901/items","type":"playlist","title":"Shared","smart":false,"readOnly":false}"#
+ )
+
+ await store.loadLibraryProviderCapabilities()
+ await store.loadPlaylists()
+
+ #expect(!store.supportsPlaylistCreation)
+ #expect(!store.supportsPlaylistManagement(for: playlist))
+ #expect(store.playlists.map(\.title) == ["Shared"])
+ await #expect(throws: PlexBrowserMutationError.self) {
+ try await store.renamePlaylist(playlist, to: "Changed")
+ }
+ #expect(scenario.methodsAndPaths == [
+ "GET /media/providers",
+ "GET /provider/playlists",
+ ])
+ }
+
+ private func makeStore(
+ handler: @escaping @Sendable (URLRequest) throws -> (HTTPURLResponse, Data)
+ ) throws -> PlexBrowserStore {
+ ManagementMockURLProtocol.requestHandler = handler
+ let sessionConfiguration = URLSessionConfiguration.ephemeral
+ sessionConfiguration.protocolClasses = [ManagementMockURLProtocol.self]
+ let session = URLSession(configuration: sessionConfiguration)
+
+ let suiteName = "PlexBarTests.collectionPlaylistManagement.\(UUID().uuidString)"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ let settings = PlexSettingsStore(
+ defaults: defaults,
+ credentialStore: PlexMemoryCredentialStore(
+ credentials: PlexStoredCredentials(userToken: "user-token", serverToken: "server-token")
+ ),
+ initialCredentials: PlexStoredCredentials(
+ userToken: "user-token",
+ serverToken: "server-token"
+ )
+ )
+ settings.selectedServerIdentifier = "server-id"
+ settings.selectedServerName = "Server"
+ let connectionStore = PlexConnectionStore(settings: settings)
+ connectionStore.activeConnection = PlexResolvedConnection(
+ serverID: "server-id",
+ url: try #require(URL(string: "https://plex.local:32400")),
+ kind: .local,
+ validatedAt: Date()
+ )
+ return PlexBrowserStore(
+ connectionStore: connectionStore,
+ client: PlexAPIClient(session: session),
+ pageSize: 100
+ )
+ }
+
+ private func makeLibrary() -> PlexLibrary {
+ PlexLibrary(
+ id: "26",
+ title: "Movies",
+ type: .movie,
+ compositePath: nil,
+ artPath: nil,
+ thumbPath: nil,
+ itemCount: 1,
+ secondaryCount: nil,
+ secondaryCountLabel: nil,
+ updatedAt: nil,
+ scannedAt: nil,
+ contentChangedAt: nil,
+ latestAddedAt: nil,
+ latestItemTitle: nil
+ )
+ }
+
+ private func decodeItem(_ json: String) throws -> PlexMediaItem {
+ try JSONDecoder().decode(PlexMediaItem.self, from: Data(json.utf8))
+ }
+}
+
+private final class CollectionManagementScenario: @unchecked Sendable {
+ private let lock = NSLock()
+ private let canManage: Bool
+ private var collectionTitle: String?
+ private var recordedMethodsAndPaths: [String] = []
+
+ init(canManage: Bool) {
+ self.canManage = canManage
+ }
+
+ var methodsAndPaths: [String] {
+ lock.withLock { recordedMethodsAndPaths }
+ }
+
+ func response(for request: URLRequest) throws -> (HTTPURLResponse, Data) {
+ try lock.withLock {
+ let url = try #require(request.url)
+ recordedMethodsAndPaths.append("\(request.httpMethod ?? "GET") \(url.path)")
+
+ switch (request.httpMethod, url.path) {
+ case ("GET", "/media/providers"):
+ return try managementResponse(
+ for: request,
+ data: providerData(canManage: canManage)
+ )
+ case ("POST", "/provider/collections"):
+ collectionTitle = queryValue("title", in: request)
+ return try managementResponse(for: request, data: collectionData(title: collectionTitle))
+ case ("PUT", "/provider/metadata/900"):
+ collectionTitle = queryValue("title", in: request)
+ return try managementResponse(for: request, data: Data())
+ case ("DELETE", "/library/sections/26/collection/900"):
+ collectionTitle = nil
+ return try managementResponse(for: request, data: Data())
+ case ("GET", "/library/sections/26/collections"):
+ return try managementResponse(for: request, data: collectionData(title: collectionTitle))
+ default:
+ Issue.record("Unexpected collection-management request: \(request)")
+ return try managementResponse(for: request, data: Data(#"{"MediaContainer":{}}"#.utf8))
+ }
+ }
+ }
+}
+
+private final class MissingCollectionFeatureScenario: @unchecked Sendable {
+ private let lock = NSLock()
+ private var recordedMethodsAndPaths: [String] = []
+
+ var methodsAndPaths: [String] {
+ lock.withLock { recordedMethodsAndPaths }
+ }
+
+ func response(for request: URLRequest) throws -> (HTTPURLResponse, Data) {
+ try lock.withLock {
+ let url = try #require(request.url)
+ recordedMethodsAndPaths.append("\(request.httpMethod ?? "GET") \(url.path)")
+ guard request.httpMethod == "GET", url.path == "/media/providers" else {
+ Issue.record("Unexpected incomplete-provider request: \(request)")
+ return try managementResponse(for: request, data: Data())
+ }
+ return try managementResponse(
+ for: request,
+ data: providerData(canManage: true, includeCollectionFeatures: false)
+ )
+ }
+ }
+}
+
+private final class PlaylistChildrenManagementScenario: @unchecked Sendable {
+ private let lock = NSLock()
+ private var orderedIDs = ["1001", "1002", "1003"]
+ private var records: [String] = []
+
+ var methodsPathsAndQueries: [String] {
+ lock.withLock { records }
+ }
+
+ func response(for request: URLRequest) throws -> (HTTPURLResponse, Data) {
+ try lock.withLock {
+ let url = try #require(request.url)
+ let query = url.query.map { "?\($0)" } ?? ""
+ records.append("\(request.httpMethod ?? "GET") \(url.path)\(query)")
+
+ switch (request.httpMethod, url.path) {
+ case ("GET", "/media/providers"):
+ return try managementResponse(for: request, data: providerData(canManage: true))
+ case ("GET", "/playlists/901/items"):
+ return try managementResponse(for: request, data: playlistChildrenData(ids: orderedIDs))
+ case ("PUT", "/provider/playlists/901/items/1002/move"):
+ orderedIDs = ["1001", "1003", "1002"]
+ return try managementResponse(for: request, data: Data())
+ case ("DELETE", "/provider/playlists/901/items/1002"):
+ orderedIDs.removeAll { $0 == "1002" }
+ return try managementResponse(for: request, data: Data())
+ default:
+ Issue.record("Unexpected playlist-management request: \(request)")
+ return try managementResponse(for: request, data: Data(#"{"MediaContainer":{}}"#.utf8))
+ }
+ }
+ }
+}
+
+private final class ReadOnlyPlaylistScenario: @unchecked Sendable {
+ private let lock = NSLock()
+ private var records: [String] = []
+
+ var methodsAndPaths: [String] {
+ lock.withLock { records }
+ }
+
+ func response(for request: URLRequest) throws -> (HTTPURLResponse, Data) {
+ try lock.withLock {
+ let url = try #require(request.url)
+ records.append("\(request.httpMethod ?? "GET") \(url.path)")
+ switch (request.httpMethod, url.path) {
+ case ("GET", "/media/providers"):
+ return try managementResponse(
+ for: request,
+ data: providerData(canManage: true, playlistReadOnly: true)
+ )
+ case ("GET", "/provider/playlists"):
+ return try managementResponse(for: request, data: readOnlyPlaylistData())
+ default:
+ Issue.record("Unexpected read-only playlist request: \(request)")
+ return try managementResponse(for: request, data: Data())
+ }
+ }
+ }
+}
+
+private final class CollectionCreateAndAddFailureScenario: @unchecked Sendable {
+ private let lock = NSLock()
+ private var records: [String] = []
+
+ var methodsAndPaths: [String] {
+ lock.withLock { records }
+ }
+
+ func response(for request: URLRequest) throws -> (HTTPURLResponse, Data) {
+ try lock.withLock {
+ let url = try #require(request.url)
+ records.append("\(request.httpMethod ?? "GET") \(url.path)")
+
+ switch (request.httpMethod, url.path) {
+ case ("GET", "/media/providers"):
+ return try managementResponse(for: request, data: providerData(canManage: true))
+ case ("POST", "/provider/collections"),
+ ("GET", "/library/sections/26/collections"):
+ return try managementResponse(
+ for: request,
+ data: collectionData(title: "Cary Grant")
+ )
+ case ("PUT", "/provider/collections/900/items"):
+ return try managementResponse(for: request, statusCode: 500, data: Data())
+ default:
+ Issue.record("Unexpected create-and-add request: \(request)")
+ return try managementResponse(for: request, data: Data())
+ }
+ }
+ }
+}
+
+private final class CollectionAddRefreshFailureScenario: @unchecked Sendable {
+ private let lock = NSLock()
+ private var collectionLoadCount = 0
+ private var recordedAddRequestCount = 0
+
+ var addRequestCount: Int {
+ lock.withLock { recordedAddRequestCount }
+ }
+
+ func response(for request: URLRequest) throws -> (HTTPURLResponse, Data) {
+ try lock.withLock {
+ let url = try #require(request.url)
+
+ switch (request.httpMethod, url.path) {
+ case ("GET", "/media/providers"):
+ return try managementResponse(for: request, data: providerData(canManage: true))
+ case ("GET", "/library/sections/26/collections"):
+ collectionLoadCount += 1
+ if collectionLoadCount == 1 {
+ return try managementResponse(
+ for: request,
+ data: collectionData(title: "Favorites")
+ )
+ }
+ return try managementResponse(for: request, statusCode: 500, data: Data())
+ case ("PUT", "/provider/collections/900/items"):
+ recordedAddRequestCount += 1
+ return try managementResponse(for: request, data: Data())
+ case ("GET", "/library/collections/900/items"):
+ return try managementResponse(for: request, statusCode: 500, data: Data())
+ default:
+ Issue.record("Unexpected add-and-refresh request: \(request)")
+ return try managementResponse(for: request, data: Data())
+ }
+ }
+ }
+}
+
+private final class ManagementMockURLProtocol: URLProtocol, @unchecked Sendable {
+ nonisolated(unsafe) static var requestHandler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))?
+
+ override static func canInit(with request: URLRequest) -> Bool { true }
+ override static func canonicalRequest(for request: URLRequest) -> URLRequest { request }
+
+ override func startLoading() {
+ guard let handler = Self.requestHandler else {
+ client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
+ return
+ }
+ do {
+ let (response, data) = try handler(request)
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: data)
+ client?.urlProtocolDidFinishLoading(self)
+ } catch {
+ client?.urlProtocol(self, didFailWithError: error)
+ }
+ }
+
+ override func stopLoading() {}
+}
+
+private func managementResponse(
+ for request: URLRequest,
+ statusCode: Int = 200,
+ data: Data
+) throws -> (HTTPURLResponse, Data) {
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: statusCode,
+ httpVersion: nil,
+ headerFields: ["X-Plex-Container-Total-Size": "3"]
+ ))
+ return (response, data)
+}
+
+private func emptyResponse(for request: URLRequest) throws -> (HTTPURLResponse, Data) {
+ try managementResponse(for: request, data: Data(#"{"MediaContainer":{}}"#.utf8))
+}
+
+private func providerData(
+ canManage: Bool,
+ playlistReadOnly: Bool = false,
+ includeCollectionFeatures: Bool = true
+) -> Data {
+ if canManage {
+ let collectionFeatures = includeCollectionFeatures
+ ? #",{"type":"collection","key":"/provider/collections?source=library"},{"type":"metadata","key":"/provider/metadata?source=library"}"#
+ : ""
+ return Data(
+ #"{"MediaContainer":{"MediaProvider":[{"identifier":"com.plexapp.plugins.library","Feature":[{"type":"timeline","key":"/timeline","scrobbleKey":"/played","unscrobbleKey":"/unplayed"}\#(collectionFeatures),{"type":"playlist","key":"/provider/playlists?source=library","readOnly":\#(playlistReadOnly)},{"type":"manage"}]}]}}"#.utf8
+ )
+ }
+ return Data(
+ #"{"MediaContainer":{"MediaProvider":[{"identifier":"com.plexapp.plugins.library","Feature":[{"type":"timeline","key":"/timeline","scrobbleKey":"/played","unscrobbleKey":"/unplayed"},{"type":"playlist","key":"/provider/playlists?source=library","readOnly":\#(playlistReadOnly)}]}]}}"#.utf8
+ )
+}
+
+private func readOnlyPlaylistData() -> Data {
+ Data(
+ #"{"MediaContainer":{"Metadata":[{"ratingKey":"901","key":"/playlists/901/items","type":"playlist","title":"Shared","smart":false,"readOnly":false}]}}"#.utf8
+ )
+}
+
+private func collectionData(title: String?) throws -> Data {
+ guard let title else {
+ return Data(#"{"MediaContainer":{"Metadata":[]}}"#.utf8)
+ }
+ return try JSONSerialization.data(withJSONObject: [
+ "MediaContainer": [
+ "Metadata": [[
+ "ratingKey": "900",
+ "key": "/library/collections/900/items",
+ "type": "collection",
+ "title": title,
+ "smart": false,
+ ]],
+ ],
+ ])
+}
+
+private func playlistChildrenData(ids: [String]) throws -> Data {
+ let metadata: [[String: Any]] = ids.enumerated().map { index, id in
+ [
+ "ratingKey": String(index + 1),
+ "playlistItemID": id,
+ "type": "movie",
+ "title": "Item \(id)",
+ ]
+ }
+ return try JSONSerialization.data(withJSONObject: [
+ "MediaContainer": ["Metadata": metadata],
+ ])
+}
+
+private func queryValue(_ name: String, in request: URLRequest) -> String? {
+ request.url
+ .flatMap { URLComponents(url: $0, resolvingAgainstBaseURL: false) }
+ .flatMap(\.queryItems)?
+ .first(where: { $0.name == name })?
+ .value
+}
diff --git a/PlexBarTests/PlexCollectionPlaylistRequestTests.swift b/PlexBarTests/PlexCollectionPlaylistRequestTests.swift
new file mode 100644
index 0000000..39d9733
--- /dev/null
+++ b/PlexBarTests/PlexCollectionPlaylistRequestTests.swift
@@ -0,0 +1,438 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+@Suite(.serialized)
+struct PlexCollectionPlaylistRequestTests {
+ @Test func collectionsUseTheSectionEndpointAndDecodeTheirReturnedItemKey() async throws {
+ let capture = RequestCapture()
+ let session = makeCollectionPlaylistMockSession { request in
+ capture.record(request)
+ return try response(for: request, data: collectionPageData())
+ }
+
+ let page = try await PlexAPIClient(session: session).fetchCollectionsPage(
+ libraryID: "26",
+ using: try configuration,
+ start: 100,
+ size: 50
+ )
+
+ let request = try #require(capture.request)
+ #expect(request.url?.path == "/library/sections/26/collections")
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Container-Start") == "100")
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Container-Size") == "50")
+ #expect(page.items.first?.childrenPath == "/library/collections/900/items")
+ #expect(page.items.first?.preferredArtworkPath == "/library/collections/900/composite/12")
+ #expect(page.totalSize == 1)
+ }
+
+ @Test func playlistsRequestTheServerHierarchyAndDecodeFoldersAndPlaylists() async throws {
+ let capture = RequestCapture()
+ let session = makeCollectionPlaylistMockSession { request in
+ capture.record(request)
+ return try response(for: request, data: playlistPageData())
+ }
+
+ let page = try await PlexAPIClient(session: session).fetchPlaylistsPage(
+ endpointPath: "/provider/playlists?source=library",
+ using: try configuration,
+ start: 25,
+ size: 25
+ )
+
+ let request = try #require(capture.request)
+ let components = try #require(
+ request.url.flatMap { URLComponents(url: $0, resolvingAgainstBaseURL: false) }
+ )
+ #expect(components.path == "/provider/playlists")
+ #expect(queryValue("source", in: request) == "library")
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Container-Start") == "25")
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Container-Size") == "25")
+ #expect(page.items.first?.type == "playlistfolder")
+ #expect(page.items.first?.childrenPath == "/playlists/folders/700/children?owned=1")
+ #expect(page.items.first?.hasChildren == true)
+ #expect(page.items.last?.childrenPath == "/playlists/901/items")
+ #expect(page.items.last?.playlistType == "video")
+ #expect(page.items.last?.smart == true)
+ }
+
+ @Test func duplicatePlaylistEntriesUsePlaylistItemIdentity() throws {
+ let first = try JSONDecoder().decode(
+ PlexMediaItem.self,
+ from: Data(#"{"ratingKey":"42","playlistItemID":"1001","title":"Charade"}"#.utf8)
+ )
+ let second = try JSONDecoder().decode(
+ PlexMediaItem.self,
+ from: Data(#"{"ratingKey":"42","playlistItemID":1002,"title":"Charade"}"#.utf8)
+ )
+
+ #expect(first.ratingKey == second.ratingKey)
+ #expect(first.id == "playlist-item:1001")
+ #expect(second.id == "playlist-item:1002")
+ #expect(first.id != second.id)
+ }
+
+ @Test func collectionMutationsUseDocumentedMethodsPathsAndIdentifiers() async throws {
+ let log = MutationRequestLog()
+ let session = makeCollectionPlaylistMockSession { request in
+ log.record(request)
+ return try response(for: request, data: collectionPageData())
+ }
+ let client = PlexAPIClient(session: session)
+
+ let created = try await client.createCollection(
+ title: "Silent Films",
+ libraryID: "26",
+ metadataTypeID: 1,
+ endpointPath: "/provider/collections?source=library",
+ using: try configuration
+ )
+ try await client.renameCollection(
+ id: "900",
+ title: "Pre-Code",
+ metadataEndpointPath: "/provider/metadata?source=library",
+ using: try configuration
+ )
+ try await client.deleteCollection(
+ id: "900",
+ libraryID: "26",
+ using: try configuration
+ )
+ try await client.removeCollectionItem(
+ id: "42",
+ fromCollectionID: "900",
+ endpointPath: "/provider/collections?source=library",
+ using: try configuration
+ )
+ try await client.moveCollectionItem(
+ id: "42",
+ inCollectionID: "900",
+ afterItemID: "41",
+ endpointPath: "/provider/collections?source=library",
+ using: try configuration
+ )
+ try await client.moveCollectionItem(
+ id: "42",
+ inCollectionID: "900",
+ afterItemID: nil,
+ endpointPath: "/provider/collections?source=library",
+ using: try configuration
+ )
+
+ #expect(created.ratingKey == "900")
+ let requests = log.requests
+ #expect(requests.count == 6)
+ #expect(requests[0].httpMethod == "POST")
+ #expect(requests[0].url?.path == "/provider/collections")
+ #expect(queryValue("source", in: requests[0]) == "library")
+ #expect(queryValue("sectionId", in: requests[0]) == "26")
+ #expect(queryValue("title", in: requests[0]) == "Silent Films")
+ #expect(queryValue("smart", in: requests[0]) == "0")
+ #expect(queryValue("type", in: requests[0]) == "1")
+ #expect(requests[1].httpMethod == "PUT")
+ #expect(requests[1].url?.path == "/provider/metadata/900")
+ #expect(queryValue("source", in: requests[1]) == "library")
+ #expect(queryValue("title", in: requests[1]) == "Pre-Code")
+ #expect(requests[2].httpMethod == "DELETE")
+ #expect(requests[2].url?.path == "/library/sections/26/collection/900")
+ #expect(requests[3].httpMethod == "PUT")
+ #expect(requests[3].url?.path == "/provider/collections/900/items/42")
+ #expect(queryValue("source", in: requests[3]) == "library")
+ #expect(requests[4].httpMethod == "PUT")
+ #expect(requests[4].url?.path == "/provider/collections/900/items/42/move")
+ #expect(queryValue("source", in: requests[4]) == "library")
+ #expect(queryValue("after", in: requests[4]) == "41")
+ #expect(requests[5].url?.path == "/provider/collections/900/items/42/move")
+ #expect(queryValue("source", in: requests[5]) == "library")
+ }
+
+ @Test func playlistMutationsUsePlaylistItemIdentityForRemovalAndMoves() async throws {
+ let log = MutationRequestLog()
+ let session = makeCollectionPlaylistMockSession { request in
+ log.record(request)
+ return try response(for: request, data: Data())
+ }
+ let client = PlexAPIClient(session: session)
+
+ try await client.renamePlaylist(
+ id: "901",
+ title: "Friday",
+ endpointPath: "/provider/playlists?source=library",
+ using: try configuration
+ )
+ try await client.deletePlaylist(
+ id: "901",
+ endpointPath: "/provider/playlists?source=library",
+ using: try configuration
+ )
+ try await client.removePlaylistItem(
+ playlistItemID: "1002",
+ fromPlaylistID: "901",
+ endpointPath: "/provider/playlists?source=library",
+ using: try configuration
+ )
+ try await client.movePlaylistItem(
+ playlistItemID: "1002",
+ inPlaylistID: "901",
+ afterPlaylistItemID: "1003",
+ endpointPath: "/provider/playlists?source=library",
+ using: try configuration
+ )
+ try await client.movePlaylistItem(
+ playlistItemID: "1002",
+ inPlaylistID: "901",
+ afterPlaylistItemID: nil,
+ endpointPath: "/provider/playlists?source=library",
+ using: try configuration
+ )
+
+ let requests = log.requests
+ #expect(requests.count == 5)
+ #expect(requests[0].httpMethod == "PUT")
+ #expect(requests[0].url?.path == "/provider/playlists/901")
+ #expect(queryValue("source", in: requests[0]) == "library")
+ #expect(queryValue("title", in: requests[0]) == "Friday")
+ #expect(requests[1].httpMethod == "DELETE")
+ #expect(requests[1].url?.path == "/provider/playlists/901")
+ #expect(queryValue("source", in: requests[1]) == "library")
+ #expect(requests[2].httpMethod == "DELETE")
+ #expect(requests[2].url?.path == "/provider/playlists/901/items/1002")
+ #expect(queryValue("source", in: requests[2]) == "library")
+ #expect(requests[3].httpMethod == "PUT")
+ #expect(requests[3].url?.path == "/provider/playlists/901/items/1002/move")
+ #expect(queryValue("source", in: requests[3]) == "library")
+ #expect(queryValue("after", in: requests[3]) == "1003")
+ #expect(requests[4].url?.path == "/provider/playlists/901/items/1002/move")
+ #expect(queryValue("source", in: requests[4]) == "library")
+ }
+
+ @Test func addAndCreateRequestsUseTheCanonicalServerItemURI() async throws {
+ let log = MutationRequestLog()
+ let session = makeCollectionPlaylistMockSession { request in
+ log.record(request)
+ let data = request.httpMethod == "POST" && request.url?.path == "/provider/playlists"
+ ? createdPlaylistData()
+ : Data()
+ return try response(for: request, data: data)
+ }
+ let client = PlexAPIClient(session: session)
+ let item = try JSONDecoder().decode(
+ PlexMediaItem.self,
+ from: Data(
+ #"{"ratingKey":"42","key":"/library/metadata/42","type":"movie","title":"Charade"}"#.utf8
+ )
+ )
+ let uri = try PlexMediaSourceURI.item(item, serverIdentifier: "server-id")
+
+ #expect(uri == "server://server-id/com.plexapp.plugins.library/library/metadata/42")
+
+ try await client.addItem(
+ uri: uri,
+ toCollectionID: "900",
+ endpointPath: "/provider/collections?source=library",
+ using: try configuration
+ )
+ let playlist = try await client.createPlaylist(
+ containingItemURI: uri,
+ endpointPath: "/provider/playlists?source=library",
+ using: try configuration
+ )
+ try await client.addItem(
+ uri: uri,
+ toPlaylistID: playlist.ratingKey,
+ endpointPath: "/provider/playlists?source=library",
+ using: try configuration
+ )
+
+ let requests = log.requests
+ #expect(requests.count == 3)
+ #expect(requests[0].httpMethod == "PUT")
+ #expect(requests[0].url?.path == "/provider/collections/900/items")
+ #expect(queryValue("source", in: requests[0]) == "library")
+ #expect(queryValue("uri", in: requests[0]) == uri)
+ #expect(requests[1].httpMethod == "POST")
+ #expect(requests[1].url?.path == "/provider/playlists")
+ #expect(queryValue("source", in: requests[1]) == "library")
+ #expect(queryValue("uri", in: requests[1]) == uri)
+ #expect(playlist.ratingKey == "901")
+ #expect(requests[2].httpMethod == "PUT")
+ #expect(requests[2].url?.path == "/provider/playlists/901/items")
+ #expect(queryValue("source", in: requests[2]) == "library")
+ #expect(queryValue("uri", in: requests[2]) == uri)
+ }
+
+ @Test func blankMutationTitlesAreRejectedBeforeARequestIsSent() async throws {
+ let log = MutationRequestLog()
+ let session = makeCollectionPlaylistMockSession { request in
+ log.record(request)
+ return try response(for: request, data: collectionPageData())
+ }
+ let client = PlexAPIClient(session: session)
+
+ await #expect(throws: PlexAPIError.self) {
+ _ = try await client.createCollection(
+ title: " \n",
+ libraryID: "26",
+ metadataTypeID: 1,
+ endpointPath: "/provider/collections",
+ using: try configuration
+ )
+ }
+ await #expect(throws: PlexAPIError.self) {
+ try await client.renamePlaylist(
+ id: "901",
+ title: "",
+ endpointPath: "/provider/playlists",
+ using: try configuration
+ )
+ }
+
+ #expect(log.requests.isEmpty)
+ }
+
+ private var configuration: PlexConnectionConfiguration {
+ get throws {
+ PlexConnectionConfiguration(
+ serverURL: try #require(PlexURLBuilder.normalizeServerURL("https://plex.local:32400")),
+ token: "server-token",
+ clientContext: PlexClientContext(clientIdentifier: "client-123")
+ )
+ }
+ }
+}
+
+private final class MutationRequestLog: @unchecked Sendable {
+ private let lock = NSLock()
+ private var recordedRequests: [URLRequest] = []
+
+ var requests: [URLRequest] {
+ lock.withLock { recordedRequests }
+ }
+
+ func record(_ request: URLRequest) {
+ lock.withLock {
+ recordedRequests.append(request)
+ }
+ }
+}
+
+private func queryValue(_ name: String, in request: URLRequest) -> String? {
+ request.url
+ .flatMap { URLComponents(url: $0, resolvingAgainstBaseURL: false) }
+ .flatMap(\.queryItems)?
+ .first(where: { $0.name == name })?
+ .value
+}
+
+private func response(for request: URLRequest, data: Data) throws -> (HTTPURLResponse, Data) {
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: ["X-Plex-Container-Total-Size": "1"]
+ ))
+ return (response, data)
+}
+
+private func collectionPageData() -> Data {
+ Data(#"""
+ {
+ "MediaContainer": {
+ "offset": 100,
+ "Metadata": [{
+ "ratingKey": "900",
+ "key": "/library/collections/900/items",
+ "type": "collection",
+ "title": "Silent Films",
+ "composite": "/library/collections/900/composite/12",
+ "leafCount": "2"
+ }]
+ }
+ }
+ """#.utf8)
+}
+
+private func playlistPageData() -> Data {
+ Data(#"""
+ {
+ "MediaContainer": {
+ "offset": 25,
+ "Metadata": [{
+ "ratingKey": "700",
+ "key": "/playlists/folders/700/children?owned=1",
+ "type": "playlistfolder",
+ "title": "Weekend"
+ }, {
+ "ratingKey": "901",
+ "key": "/playlists/901/items",
+ "type": "playlist",
+ "title": "Movie Night",
+ "composite": "/playlists/901/composite/12",
+ "duration": 7200000,
+ "leafCount": 2,
+ "playlistType": "video",
+ "smart": "1"
+ }]
+ }
+ }
+ """#.utf8)
+}
+
+private func createdPlaylistData() -> Data {
+ Data(#"""
+ {
+ "MediaContainer": {
+ "Metadata": [{
+ "ratingKey": "901",
+ "key": "/playlists/901/items",
+ "type": "playlist",
+ "title": "Playlist",
+ "playlistType": "video",
+ "smart": false,
+ "readOnly": false
+ }]
+ }
+ }
+ """#.utf8)
+}
+
+private func makeCollectionPlaylistMockSession(
+ handler: @escaping @Sendable (URLRequest) throws -> (HTTPURLResponse, Data)
+) -> URLSession {
+ CollectionPlaylistMockURLProtocol.requestHandler = handler
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.protocolClasses = [CollectionPlaylistMockURLProtocol.self]
+ return URLSession(configuration: configuration)
+}
+
+private final class CollectionPlaylistMockURLProtocol: URLProtocol, @unchecked Sendable {
+ nonisolated(unsafe) static var requestHandler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))?
+
+ override static func canInit(with request: URLRequest) -> Bool {
+ true
+ }
+
+ override static func canonicalRequest(for request: URLRequest) -> URLRequest {
+ request
+ }
+
+ override func startLoading() {
+ guard let handler = Self.requestHandler else {
+ client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
+ return
+ }
+
+ do {
+ let (response, data) = try handler(request)
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: data)
+ client?.urlProtocolDidFinishLoading(self)
+ } catch {
+ client?.urlProtocol(self, didFailWithError: error)
+ }
+ }
+
+ override func stopLoading() {}
+}
diff --git a/Tests/PlexBarTests/PlexConnectionResolverTests.swift b/PlexBarTests/PlexConnectionResolverTests.swift
similarity index 80%
rename from Tests/PlexBarTests/PlexConnectionResolverTests.swift
rename to PlexBarTests/PlexConnectionResolverTests.swift
index 1d1d467..89e79d4 100644
--- a/Tests/PlexBarTests/PlexConnectionResolverTests.swift
+++ b/PlexBarTests/PlexConnectionResolverTests.swift
@@ -1,9 +1,47 @@
+import PlexModels
import Foundation
import Testing
@testable import PlexBar
@Suite(.serialized)
struct PlexConnectionResolverTests {
+ @Test func unreachableConnectionsPreserveUnderlyingTransportCauses() async throws {
+ let session = makeResolverSession { request in
+ throw URLError(request.url?.host == "plex.local" ? .timedOut : .cannotConnectToHost)
+ }
+ defer { session.invalidateAndCancel() }
+ let resolver = PlexConnectionResolver(client: PlexAPIClient(session: session))
+ do {
+ _ = try await resolver.resolve(
+ server: makeServer(id: "server-id", connections: [
+ .init(uri: URL(string: "https://plex.local:32400")!, local: true, relay: false),
+ .init(uri: URL(string: "https://plex.remote:32400")!, local: false, relay: false)
+ ]),
+ clientContext: PlexClientContext(clientIdentifier: "client-123"),
+ cachedURL: nil
+ )
+ Issue.record("Expected unreachable connections")
+ } catch let failure as PlexServerConnectionFailure {
+ #expect(failure.failureCodes == [.timedOut, .cannotConnectToHost])
+ #expect(failure.localizedDescription.contains(URLError(.timedOut).localizedDescription))
+ #expect(failure.localizedDescription.contains(URLError(.cannotConnectToHost).localizedDescription))
+ }
+ }
+
+ @Test func preferredConnectionUsesSharedDeterministicRanking() throws {
+ let server = makeServer(
+ id: "server-id",
+ connections: [
+ .init(uri: try #require(URL(string: "https://plex.relay:8443")), local: false, relay: true),
+ .init(uri: try #require(URL(string: "http://plex.local:32400")), local: true, relay: false),
+ .init(uri: try #require(URL(string: "https://plex.local:32400")), local: true, relay: false),
+ .init(uri: try #require(URL(string: "https://plex.remote:32400")), local: false, relay: false),
+ ]
+ )
+
+ #expect(server.preferredConnection?.uri.absoluteString == "https://plex.local:32400")
+ }
+
@Test func prefersReachableLocalConnection() async throws {
let session = makeResolverSession { request in
let url = try #require(request.url)
@@ -449,7 +487,7 @@ struct PlexConnectionResolverTests {
)
connectionStore.updateAvailableServers([server])
- await #expect(throws: PlexConnectionResolutionError.self) {
+ await #expect(throws: PlexServerConnectionFailure.self) {
try await connectionStore.currentConfiguration()
}
#expect(connectionStore.errorMessage != nil)
@@ -461,6 +499,87 @@ struct PlexConnectionResolverTests {
#expect(connectionStore.resolvedServerURL?.host == "plex.local")
#expect(connectionStore.activeConnection?.url.host == "plex.local")
}
+
+ @MainActor
+ @Test func requestResultIsRejectedWhenTheAccountCredentialChangesMidFlight() async throws {
+ let suiteName = "PlexBarTests.requestResultIsRejectedWhenTheAccountCredentialChangesMidFlight"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let firstToken = "first-server-token"
+ let settings = PlexSettingsStore(
+ defaults: defaults,
+ credentialStore: PlexMemoryCredentialStore(
+ credentials: PlexStoredCredentials(userToken: "user-token", serverToken: firstToken)
+ ),
+ initialCredentials: PlexStoredCredentials(userToken: "user-token", serverToken: firstToken)
+ )
+ settings.selectedServerIdentifier = "server-id"
+ let connectionStore = PlexConnectionStore(settings: settings)
+ connectionStore.activeConnection = PlexResolvedConnection(
+ serverID: "server-id",
+ url: try #require(URL(string: "https://plex.local:32400")),
+ kind: .local,
+ validatedAt: Date()
+ )
+ let gate = AccountScopedRequestGate()
+
+ let request = Task {
+ try await connectionStore.perform { configuration in
+ await gate.suspendRequest()
+ return configuration.token
+ }
+ }
+
+ await gate.waitUntilRequestStarts()
+ settings.serverToken = "second-server-token"
+ await gate.finishRequest()
+
+ do {
+ _ = try await request.value
+ Issue.record("A response from the previous account scope was accepted.")
+ } catch is CancellationError {
+ // Expected: the response belongs to the credential that started the request.
+ } catch {
+ Issue.record("Expected CancellationError, received \(error)")
+ }
+
+ #expect(connectionStore.accountCacheScope != PlexConnectionConfiguration.accountCacheScope(
+ serverIdentifier: "server-id",
+ token: firstToken
+ ))
+ #expect(!connectionStore.accountCacheScope.contains("second-server-token"))
+ }
+}
+
+private actor AccountScopedRequestGate {
+ private var hasStarted = false
+ private var startContinuation: CheckedContinuation?
+ private var requestContinuation: CheckedContinuation?
+
+ func suspendRequest() async {
+ hasStarted = true
+ startContinuation?.resume()
+ startContinuation = nil
+ await withCheckedContinuation { continuation in
+ requestContinuation = continuation
+ }
+ }
+
+ func waitUntilRequestStarts() async {
+ guard !hasStarted else {
+ return
+ }
+ await withCheckedContinuation { continuation in
+ startContinuation = continuation
+ }
+ }
+
+ func finishRequest() {
+ requestContinuation?.resume()
+ requestContinuation = nil
+ }
}
private func makeServer(
diff --git a/PlexBarTests/PlexDebugMockServerTests.swift b/PlexBarTests/PlexDebugMockServerTests.swift
new file mode 100644
index 0000000..d1aaf97
--- /dev/null
+++ b/PlexBarTests/PlexDebugMockServerTests.swift
@@ -0,0 +1,615 @@
+import PlexMockData
+import Foundation
+import Testing
+@testable import PlexBar
+
+#if DEBUG
+@Suite struct PlexDebugMockServerTests {
+ @Test func mockSessionProvidesAuthBootstrapEndpoints() async throws {
+ let session = PlexDebugMockServer.makeSession()
+ let authClient = PlexAuthClient(session: session)
+ let clientContext = PlexClientContext(clientIdentifier: "tests")
+
+ let authenticatedUser = try await authClient.fetchAuthenticatedUser(
+ userToken: PlexDebugMockServer.mockUserToken,
+ clientContext: clientContext
+ )
+ let servers = try await authClient.fetchServers(
+ userToken: PlexDebugMockServer.mockUserToken,
+ clientContext: clientContext
+ )
+ let nonce = try await authClient.fetchJWTNonce(clientContext: clientContext)
+ let refreshedToken = try await authClient.exchangeDeviceJWT(
+ "signed.device.jwt",
+ clientContext: clientContext
+ )
+
+ #expect(authenticatedUser.displayName == "D0loresH4ze")
+ #expect(authenticatedUser.displayEmail == "d0loresh4ze@proton.me")
+ #expect(authenticatedUser.displayUsername == nil)
+ #expect(authenticatedUser.thumb?.hasPrefix("file://") == true)
+ #expect(servers.count == 1)
+ #expect(servers.first?.id == "debug-mock-server")
+ #expect(nonce == "plexbar-mock-nonce")
+ #expect(refreshedToken == PlexDebugMockServer.mockUserToken)
+ }
+
+ @Test func mockAuthenticatedUserAvatarLoadsAtRequestedSizesAfterCacheEviction() async throws {
+ let session = PlexDebugMockServer.makeSession()
+ let authClient = PlexAuthClient(session: session)
+ let authenticatedUser = try await authClient.fetchAuthenticatedUser(
+ userToken: PlexDebugMockServer.mockUserToken,
+ clientContext: PlexClientContext(clientIdentifier: "tests")
+ )
+ let thumbURL = try #require(authenticatedUser.thumb.flatMap(URL.init(string:)))
+ let imageClient = PlexImageClient(
+ cache: PlexImageMemoryCache(imageCountLimit: 1),
+ requestCoordinator: PlexImageRequestCoordinator()
+ )
+
+ #expect(thumbURL.isFileURL)
+ #expect(thumbURL.lastPathComponent == "darlene-alderson.png")
+ // The second size evicts the first. The third load must read the file again.
+ for size in [60, 120, 60] {
+ #expect(imageClient.cachedCGImageResult(from: [thumbURL], token: "", maximumPixelSize: size) == nil)
+ let result = try #require(await imageClient.fetchCGImageResult(
+ from: [thumbURL],
+ token: "",
+ clientContext: PlexClientContext(clientIdentifier: "tests"),
+ maximumPixelSize: size
+ ))
+ #expect(result.sourceURL == thumbURL)
+ #expect(result.image.width == size)
+ #expect(result.image.height == size)
+ }
+ }
+
+ @Test func loadsMockServerPayloadFromResources() throws {
+ let payload = try PlexMockServerPayload.loadDefault()
+ let catalog = try PlexMockMediaCatalog.loadDefault()
+ let hasTommyAudiobookSession = payload.activeSessions.contains { session in
+ session.userID == 15 && session.mediaType == "track" && session.mediaID == "32301"
+ }
+ let historyCountsByUser = Dictionary(
+ uniqueKeysWithValues: Dictionary(grouping: payload.historyEvents, by: \.userID)
+ .map { ($0.key, $0.value.count) }
+ )
+
+ #expect(payload.server.name == "Mock Server")
+ #expect(payload.activeSessions.count == 4)
+ #expect(payload.libraries.map(\.title) == ["Movies", "TV Shows", "Audiobooks"])
+ let userNames = [
+ "scully", "Elliot", "petit_prince", "popeye23", "TommyS", "D0loresH4ze", "scrump-toggins", "TheBaumer", "Le0n", "Joi"
+ ]
+ #expect(payload.users.map(\.name) == userNames)
+ #expect(payload.users.first(where: { $0.id == 17 })?.avatar == "/mock/avatars/scrump-toggins.png")
+ #expect(payload.historyEvents.filter { $0.userID == 17 }.count == 3)
+ #expect(historyCountsByUser == [11: 4, 12: 3, 13: 1, 14: 2, 15: 3, 16: 2, 17: 3, 18: 3, 19: 3, 20: 3])
+ #expect(payload.historyEvents.contains(where: { $0.mediaType == "episode" }))
+ #expect(hasTommyAudiobookSession)
+ #expect(payload.activeSessions.first(where: { $0.sessionKey == "stream-4" })?.audioStream?.id == 3_103_001)
+ #expect(payload.activeSessions.first(where: { $0.sessionKey == "stream-4" })?.audioStream?.levels.count == 96)
+ #expect(catalog.records.filter { $0.item.type == "episode" }.count == 32)
+ #expect(catalog.records.filter { $0.item.type == "show" }.count == 12)
+ }
+
+ @Test func mockServerReturnsCanonicalLibraries() async throws {
+ let client = PlexAPIClient(session: PlexDebugMockServer.makeSession())
+ let libraries = try await client.fetchLibraries(
+ using: PlexConnectionConfiguration(
+ serverURL: URL(string: "https://demo.plexbar.local:32400")!,
+ token: "plexbar-debug-mock-server-token",
+ clientContext: PlexClientContext(clientIdentifier: "tests")
+ )
+ )
+
+ let librariesByTitle = Dictionary(uniqueKeysWithValues: libraries.map { ($0.title, $0) })
+
+ #expect(Set(librariesByTitle.keys) == ["Movies", "TV Shows", "Audiobooks"])
+ #expect(librariesByTitle["Movies"]?.type == .movie)
+ #expect(librariesByTitle["Movies"]?.latestItemTitle == "All Quiet on the Western Front")
+ #expect(librariesByTitle["TV Shows"]?.type == .show)
+ #expect(librariesByTitle["TV Shows"]?.itemCount == 12)
+ #expect(librariesByTitle["TV Shows"]?.secondaryCount == 18)
+ #expect(librariesByTitle["TV Shows"]?.secondaryCountLabel == "seasons")
+ #expect(librariesByTitle["TV Shows"]?.latestItemTitle == "One Step Beyond")
+ #expect(librariesByTitle["Audiobooks"]?.type == .artist)
+ #expect(librariesByTitle["Audiobooks"]?.itemCount == 8)
+ #expect(librariesByTitle["Audiobooks"]?.secondaryCount == 10)
+ #expect(librariesByTitle["Audiobooks"]?.secondaryCountLabel == "albums")
+ #expect(librariesByTitle["Audiobooks"]?.latestItemTitle == "Alexandre Dumas")
+ }
+
+ @Test func mockServerFiltersLibrarySearchByTitle() async throws {
+ let client = PlexAPIClient(session: PlexDebugMockServer.makeSession())
+ let configuration = PlexConnectionConfiguration(
+ serverURL: URL(string: "https://demo.plexbar.local:32400")!,
+ token: "plexbar-debug-mock-server-token",
+ clientContext: PlexClientContext(clientIdentifier: "tests")
+ )
+ let libraries = try await client.fetchLibraries(using: configuration)
+ let movies = try #require(libraries.first(where: { $0.title == "Movies" }))
+
+ let page = try await client.fetchMediaPage(
+ libraryID: movies.id,
+ using: configuration,
+ searchQuery: "night"
+ )
+
+ #expect(page.items.map(\.title) == ["Night of the Living Dead"])
+ #expect(page.totalSize == 1)
+ }
+
+ @Test func mockServerDescribesAndAppliesLibrarySorts() async throws {
+ let client = PlexAPIClient(session: PlexDebugMockServer.makeSession())
+ let configuration = PlexConnectionConfiguration(
+ serverURL: URL(string: "https://demo.plexbar.local:32400")!,
+ token: "plexbar-debug-mock-server-token",
+ clientContext: PlexClientContext(clientIdentifier: "tests")
+ )
+ let libraries = try await client.fetchLibraries(using: configuration)
+ let movies = try #require(libraries.first { $0.title == "Movies" })
+ let endpoints = try await client.fetchLibraryProviderEndpoints(using: configuration)
+ let route = try #require(endpoints.browseRoute(for: movies.id))
+ let definition = try await client.fetchLibraryBrowseDefinition(
+ sectionPath: route.sectionPath,
+ contentPath: route.contentPath,
+ using: configuration
+ )
+ let nameSort = try #require(definition.sorts.first { $0.id == "titleSort" })
+ let descendingName = try #require(nameSort.selection(direction: .descending))
+
+ let page = try await client.fetchMediaPage(
+ contentPath: definition.contentPath,
+ using: configuration,
+ browseOptions: PlexLibraryBrowseOptions(sort: descendingName)
+ )
+
+ #expect(definition.booleanFilters.map(\.id) == ["unwatched", "inProgress"])
+ #expect(definition.sorts.map(\.title) == ["Name", "Date Added"])
+ #expect(page.items.map(\.title) == ["The Stranger", "The Phantom of the Opera", "The Lost World", "The Little Shop of Horrors", "The Last Man on Earth", "The General", "The Bat", "Sherlock Jr.", "Reefer Madness", "Plan 9 from Outer Space", "Nosferatu", "Night of the Living Dead", "My Man Godfrey", "Metropolis", "It's a Wonderful Life", "Fear and Desire", "Charade", "Animal Crackers", "All Quiet on the Western Front", "A Star Is Born"])
+ }
+
+ @Test func mockServerReturnsBrowsableCollectionsAndPlaylists() async throws {
+ let client = PlexAPIClient(session: PlexDebugMockServer.makeSession())
+ let configuration = PlexConnectionConfiguration(
+ serverURL: URL(string: "https://demo.plexbar.local:32400")!,
+ token: "plexbar-debug-mock-server-token",
+ clientContext: PlexClientContext(clientIdentifier: "tests")
+ )
+ let libraries = try await client.fetchLibraries(using: configuration)
+ let movies = try #require(libraries.first { $0.title == "Movies" })
+ let collections = try await client.fetchCollectionsPage(
+ libraryID: movies.id,
+ using: configuration
+ )
+ let collection = try #require(collections.items.first)
+ let collectionItems = try await client.fetchMediaChildren(
+ of: collection,
+ using: configuration
+ )
+ let playlists = try await client.fetchPlaylistsPage(
+ endpointPath: "/playlists",
+ using: configuration
+ )
+ let videoPlaylist = try #require(playlists.items.first { $0.playlistType == "video" })
+ let playlistItems = try await client.fetchMediaChildren(
+ of: videoPlaylist,
+ using: configuration
+ )
+
+ #expect(collection.title == "Movies Collection")
+ #expect(collection.childrenPath == "/library/collections/9101/items")
+ #expect(collectionItems.items.map(\.title) == ["All Quiet on the Western Front", "Animal Crackers", "Charade", "Night of the Living Dead", "Sherlock Jr.", "Nosferatu", "Metropolis", "The Lost World", "The General", "The Phantom of the Opera", "The Little Shop of Horrors", "A Star Is Born", "My Man Godfrey", "The Stranger", "Plan 9 from Outer Space", "It's a Wonderful Life", "Fear and Desire", "The Bat", "The Last Man on Earth", "Reefer Madness"])
+ #expect(playlists.items.map(\.title) == ["Video Playlist", "Audio Playlist"])
+ #expect(videoPlaylist.childrenPath == "/playlists/9201/items")
+ #expect(playlistItems.items.allSatisfy { $0.playlistItemID != nil })
+ }
+
+ @Test func mockServerReturnsPromotedHomeHubsAndTheirExactContentKeys() async throws {
+ let client = PlexAPIClient(session: PlexDebugMockServer.makeSession())
+ let configuration = PlexConnectionConfiguration(
+ serverURL: URL(string: "https://demo.plexbar.local:32400")!,
+ token: "plexbar-debug-mock-server-token",
+ clientContext: PlexClientContext(clientIdentifier: "tests")
+ )
+ let endpoints = try await client.fetchLibraryProviderEndpoints(using: configuration)
+ let promotedPath = try #require(endpoints.promotedPath)
+ let hubs = try await client.fetchHubs(
+ endpointPath: promotedPath,
+ using: configuration,
+ count: 2
+ )
+ let moviesHub = try #require(hubs.first { $0.title == "Recently Added Movies" })
+ let path = try #require(moviesHub.key)
+ let page = try await client.fetchMediaPage(
+ contentPath: path,
+ using: configuration,
+ start: 0,
+ size: 2
+ )
+
+ #expect(hubs.map(\.title) == [
+ "Recently Added Movies",
+ "Recently Added TV Shows",
+ "Recently Added Audiobooks"
+ ])
+ #expect(moviesHub.metadata.count == 2)
+ #expect(moviesHub.more)
+ #expect(path == "/hubs/home/recentlyAdded?type=1")
+ #expect(page.items.map(\.title) == ["All Quiet on the Western Front", "Animal Crackers"])
+ #expect(page.totalSize == 20)
+ }
+
+ @Test func mockServerServesTranscodedPosterArtwork() async throws {
+ let session = PlexDebugMockServer.makeSession()
+ let imageClient = PlexImageClient(session: session)
+ let clientContext = PlexClientContext(clientIdentifier: "tests")
+ let posterURL = try #require(PlexURLBuilder.transcodedArtworkURL(
+ serverURL: URL(string: "https://demo.plexbar.local:32400")!,
+ path: "/mock/art/movies/charade/poster.png",
+ width: 176,
+ height: 264
+ ))
+
+ let image = await imageClient.fetchImage(
+ from: [posterURL],
+ token: "plexbar-debug-mock-server-token",
+ clientContext: clientContext
+ )
+
+ #expect(image != nil)
+ }
+
+ @Test func mockServerReturnsTVHistoryAndSeriesMetadata() async throws {
+ let client = PlexAPIClient(session: PlexDebugMockServer.makeSession())
+ let configuration = PlexConnectionConfiguration(
+ serverURL: URL(string: "https://demo.plexbar.local:32400")!,
+ token: "plexbar-debug-mock-server-token",
+ clientContext: PlexClientContext(clientIdentifier: "tests")
+ )
+
+ let history = try await client.fetchHistory(
+ using: configuration,
+ since: Date(timeIntervalSinceNow: -60 * 60 * 24 * 30)
+ )
+ let episodeIDs = history.compactMap(\.episodeMetadataItemID)
+ let seriesByEpisodeID = try await client.fetchHistorySeriesIdentities(
+ using: configuration,
+ episodeIDs: episodeIDs
+ )
+
+ #expect(history.contains(where: { $0.contentKind == .tv }))
+ #expect(seriesByEpisodeID["2201"]?.title == "One Step Beyond")
+ #expect(seriesByEpisodeID["2202"]?.title == "The Adventures of Ozzie and Harriet")
+ #expect(seriesByEpisodeID["2203"]?.title == "The Abbott and Costello Show")
+ }
+
+ @Test func mockServerReturnsRealAudiobookSessionShape() async throws {
+ let client = PlexAPIClient(session: PlexDebugMockServer.makeSession())
+ let configuration = PlexConnectionConfiguration(
+ serverURL: URL(string: "https://demo.plexbar.local:32400")!,
+ token: "plexbar-debug-mock-server-token",
+ clientContext: PlexClientContext(clientIdentifier: "tests")
+ )
+
+ let sessions = try await client.fetchSessions(using: configuration)
+ let tommySession = try #require(sessions.first(where: { $0.canonicalSessionKey == "stream-4" }))
+
+ #expect(tommySession.type == "track")
+ #expect(tommySession.grandparentTitle == "H. G. Wells")
+ #expect(tommySession.parentTitle == "The War of the Worlds")
+ #expect(tommySession.title == "Book 1, Chapter 1")
+ #expect(tommySession.duration == 939_000)
+ #expect(tommySession.parentThumb == "/mock/art/audiobooks/war-of-the-worlds/cover.png")
+ #expect(tommySession.thumb == "/mock/art/audiobooks/war-of-the-worlds/cover.png")
+ #expect(tommySession.player.product == "Prologue")
+ #expect(tommySession.player.title == "iPhone")
+ #expect(tommySession.audioStreamID == 3_103_001)
+ }
+
+ @Test func mockServerReturnsAudiobookStreamLevels() async throws {
+ let client = PlexAPIClient(session: PlexDebugMockServer.makeSession())
+ let configuration = PlexConnectionConfiguration(
+ serverURL: URL(string: "https://demo.plexbar.local:32400")!,
+ token: "plexbar-debug-mock-server-token",
+ clientContext: PlexClientContext(clientIdentifier: "tests")
+ )
+
+ let sessions = try await client.fetchSessions(using: configuration)
+ let tommySession = try #require(sessions.first(where: { $0.canonicalSessionKey == "stream-4" }))
+ let streamID = try #require(tommySession.audioStreamID)
+ let levels = try await client.fetchStreamLevels(
+ using: configuration,
+ streamID: streamID,
+ subsample: 96
+ )
+
+ #expect(streamID == 3_103_001)
+ #expect(levels.count == 96)
+ #expect(levels.min() == -39.9)
+ #expect(levels.max() == -21.2)
+ }
+
+ @Test func mockServerRemovesTerminatedSessions() async throws {
+ let client = PlexAPIClient(session: PlexDebugMockServer.makeSession())
+ let configuration = PlexConnectionConfiguration(
+ serverURL: URL(string: "https://demo.plexbar.local:32400")!,
+ token: "plexbar-debug-mock-server-token",
+ clientContext: PlexClientContext(clientIdentifier: "tests")
+ )
+ let sessions = try await client.fetchSessions(using: configuration)
+ let session = try #require(sessions.first)
+ let sessionID = try #require(session.serverSessionID)
+
+ try await client.terminateSession(using: configuration, sessionID: sessionID)
+
+ let refreshedSessions = try await client.fetchSessions(using: configuration)
+ #expect(refreshedSessions.contains(where: { $0.serverSessionID == sessionID }) == false)
+ }
+
+ @Test func mockProfilesResolveTheSameDevicesInActivityAndHistory() async throws {
+ let client = PlexAPIClient(session: PlexDebugMockServer.makeSession())
+ let payload = try PlexMockServerPayload.loadDefault()
+ let directory = try await client.fetchHistoryIdentityDirectory(using: configuration)
+ let sessions = try await client.fetchSessions(using: configuration)
+ let history = try await client.fetchHistory(using: configuration, since: .distantPast)
+ let devicesByID = Dictionary(uniqueKeysWithValues: directory.devices.map { ($0.id, $0) })
+ #expect(directory.devices.count == payload.users.flatMap(\.devices).count)
+ for event in history where event.deviceID != nil {
+ #expect(event.playbackDevice(using: devicesByID) != nil)
+ }
+ for activity in payload.activeSessions {
+ let device = try #require(devicesByID[activity.deviceID])
+ let session = try #require(sessions.first { $0.canonicalSessionKey == activity.sessionKey })
+ #expect(session.player.title == device.name)
+ #expect(session.player.platform == device.platform)
+ #expect(session.player.state == activity.state)
+ }
+ }
+
+ private var configuration: PlexConnectionConfiguration {
+ PlexConnectionConfiguration(
+ serverURL: PlexDebugMockServer.mockResolvedConnection.url,
+ token: PlexDebugMockServer.mockServer.accessToken,
+ clientContext: PlexClientContext(clientIdentifier: "catalog-tests")
+ )
+ }
+
+ @Test func everyLibraryRootResolvesRichDetailsAndItsCompleteHierarchy() async throws {
+ let client = PlexAPIClient(session: PlexDebugMockServer.makeSession())
+ let endpoints = try await client.fetchLibraryProviderEndpoints(using: configuration)
+ let libraries = try await client.fetchLibraries(using: configuration)
+ for library in libraries {
+ let route = try #require(endpoints.browseRoute(for: library.id))
+ let page = try await client.fetchMediaPage(contentPath: route.contentPath, using: configuration)
+ #expect(page.totalSize == page.items.count)
+ for root in page.items {
+ #expect(root.type != nil)
+ #expect(root.librarySectionID == library.id)
+ let detail = try await client.fetchMediaMetadata(ratingKey: root.ratingKey, using: configuration)
+ #expect(detail == root)
+ #expect(detail.summary?.isEmpty == false)
+ if detail.hasChildren {
+ let children = try await client.fetchMediaChildren(of: detail, using: configuration)
+ #expect(children.items.count == detail.childCount)
+ for child in children.items {
+ #expect(child.parentRatingKey == detail.ratingKey)
+ let firstPage = try await client.fetchMediaChildren(of: child, using: configuration)
+ let totalSize = try #require(firstPage.totalSize)
+ var leaves = firstPage.items
+ while leaves.count < totalSize {
+ let nextPage = try await client.fetchMediaChildren(
+ of: child, using: configuration, start: leaves.count
+ )
+ try #require(!nextPage.items.isEmpty)
+ leaves += nextPage.items
+ }
+ #expect(!leaves.isEmpty)
+ #expect(leaves.count == child.leafCount)
+ #expect(Set(leaves.map(\.ratingKey)).count == leaves.count)
+ for leaf in leaves {
+ #expect(leaf.parentRatingKey == child.ratingKey)
+ #expect(leaf.grandparentRatingKey == detail.ratingKey)
+ #expect(leaf.duration.map { $0 > 0 } == true)
+ let refreshed = try await client.fetchMediaMetadata(ratingKey: leaf.ratingKey, using: configuration)
+ #expect(refreshed == leaf)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @Test func filmDetailsRetainVerifiedCreditsRatingsAndReleaseMetadata() async throws {
+ let client = PlexAPIClient(session: PlexDebugMockServer.makeSession())
+ let film = try await client.fetchMediaMetadata(ratingKey: "1101", using: configuration)
+ #expect(film.title == "Charade")
+ #expect(film.year == 1963)
+ #expect(film.originallyAvailableAt == "1963-12-05")
+ #expect(film.duration == 6_780_000)
+ #expect(film.directors.map(\.tag) == ["Stanley Donen"])
+ #expect(film.roles.contains { $0.tag == "Audrey Hepburn" && $0.role == "Regina Lampert" })
+ #expect(film.art != film.thumb)
+ #expect(PlexExternalRatingsPresentation(item: film, locale: Locale(identifier: "en_US"))
+ .ratings.first { $0.source == .rottenTomatoes }?.displayValue == "95%")
+ #expect(film.progress != nil)
+ }
+
+ @Test func sourcedIMDbRatingsProduceLinksToTheMatchingTitles() async throws {
+ let client = PlexAPIClient(session: PlexDebugMockServer.makeSession())
+ let expected = [
+ ("1101", "tt0056923", "7.8"), ("1102", "tt0063350", "7.8"),
+ ("1103", "tt0015324", "8.1"), ("2101", "tt0052442", "7.8"),
+ ("2102", "tt0044230", "7.4"), ("2103", "tt0044229", "8.1"),
+ ("2201", "tt0507807", "7.1"), ("2204", "tt0507795", "7.1"),
+ ("2205", "tt0507773", "6.6"), ("2203", "tt0504552", "7.7")
+ ]
+ for (ratingKey, identifier, score) in expected {
+ let item = try await client.fetchMediaMetadata(ratingKey: ratingKey, using: configuration)
+ let presentation = PlexExternalRatingsPresentation(item: item, locale: Locale(identifier: "en_US"))
+ let rating = try #require(presentation.ratings.first { $0.source == .imdb })
+ #expect(rating.displayValue == score)
+ #expect(rating.destinationURL?.absoluteString == "https://www.imdb.com/title/\(identifier)/")
+ }
+ // A verified episode score must not link to its parent show's IMDb page.
+ let david = try await client.fetchMediaMetadata(ratingKey: "2202", using: configuration)
+ let rating = try #require(PlexExternalRatingsPresentation(item: david).ratings.first { $0.source == .imdb })
+ #expect(rating.destinationURL == nil)
+ }
+
+ @Test func correctedEpisodeIdentitiesAreSharedWithHistory() async throws {
+ let client = PlexAPIClient(session: PlexDebugMockServer.makeSession())
+ let bride = try await client.fetchMediaMetadata(ratingKey: "2201", using: configuration)
+ #expect(bride.index == 1)
+ #expect(bride.parentIndex == 1)
+ let david = try await client.fetchMediaMetadata(ratingKey: "2202", using: configuration)
+ #expect(david.title == "David the Babysitter")
+ #expect(david.index == 7)
+ #expect(david.originallyAvailableAt == "1952-11-14")
+ let dentist = try await client.fetchMediaMetadata(ratingKey: "2203", using: configuration)
+ #expect(dentist.title == "The Dentist's Office")
+ #expect(dentist.index == 2)
+ #expect(dentist.originallyAvailableAt == "1952-12-12")
+ let history = try await client.fetchHistory(
+ using: configuration, since: .distantPast, metadataItemID: 2102, pageSize: 1
+ )
+ #expect(!history.isEmpty)
+ #expect(history.allSatisfy { $0.ratingKey == david.ratingKey && $0.title == david.title })
+ let futureHistory = try await client.fetchHistory(using: configuration, since: .distantFuture)
+ #expect(futureHistory.isEmpty)
+ }
+
+ @Test func advertisedPlaylistsLoadTracksWithDistinctItemIdentities() async throws {
+ let client = PlexAPIClient(session: PlexDebugMockServer.makeSession())
+ let endpoints = try await client.fetchLibraryProviderEndpoints(using: configuration)
+ #expect(!endpoints.supportsPlayQueues)
+ #expect(!endpoints.supportsTimeline)
+ #expect(!endpoints.supportsPlaylistManagement)
+ let path = try #require(endpoints.playlistPath)
+ let playlists = try await client.fetchPlaylistsPage(endpointPath: path, using: configuration)
+ let audio = try #require(playlists.items.first { $0.playlistType == "audio" })
+ let trackCount = try #require(audio.leafCount)
+ let tracks = try await client.fetchMediaChildren(of: audio, using: configuration, size: trackCount)
+ #expect(tracks.items.count == trackCount)
+ #expect(tracks.items.allSatisfy { $0.type == "track" && $0.playlistItemID != nil })
+ #expect(Set(tracks.items.map(\.id)).count == tracks.items.count)
+ for playlist in playlists.items {
+ let detail = try await client.fetchMediaMetadata(ratingKey: playlist.ratingKey, using: configuration)
+ #expect(detail == playlist)
+ }
+ }
+
+ @Test func advertisedSearchPagesPreserveQueriesAndBoundaries() async throws {
+ let client = PlexAPIClient(session: PlexDebugMockServer.makeSession())
+ let endpoints = try await client.fetchLibraryProviderEndpoints(using: configuration)
+ let path = try #require(endpoints.searchPath)
+ let hubs = try await client.fetchSearchHubs(query: "H. G. Wells", endpointPath: path, using: configuration, limit: 2)
+ let tracks = try #require(hubs.first { $0.type == "track" })
+ #expect(tracks.metadata.count == 2)
+ #expect(tracks.more)
+ let next = try await client.fetchMediaPage(
+ contentPath: try #require(tracks.key), using: configuration, start: 2, size: 2
+ )
+ #expect(next.items.count == 2)
+ #expect(next.totalSize == tracks.totalSize)
+ #expect(Set(next.items.map(\.id)).isDisjoint(with: tracks.metadata.map(\.id)))
+ let empty = try await client.fetchSearchHubs(query: "no matching title", endpointPath: path, using: configuration)
+ #expect(empty.isEmpty)
+ }
+
+ @Test func watchFiltersAgreeWithContinueWatchingAndPaginationTotals() async throws {
+ let client = PlexAPIClient(session: PlexDebugMockServer.makeSession())
+ let endpoints = try await client.fetchLibraryProviderEndpoints(using: configuration)
+ let hubs = try await client.fetchHomeHubs(endpoints: endpoints, using: configuration, count: 1)
+ let continuation = try #require(hubs.first { $0.isContinueWatching })
+ #expect(continuation.metadata.count == 1)
+ #expect(continuation.more)
+ let all = try await client.fetchMediaPage(contentPath: try #require(continuation.key), using: configuration)
+ #expect(all.items.allSatisfy { ($0.viewOffset ?? 0) > 0 && !$0.isWatched })
+ let moviesPath = try #require(endpoints.browseRoute(for: "library-movies")?.contentPath)
+ let unwatched = try await client.fetchMediaPage(
+ contentPath: moviesPath, using: configuration,
+ browseOptions: PlexLibraryBrowseOptions(enabledBooleanFilterIDs: ["unwatched"])
+ )
+ #expect(unwatched.items.map(\.ratingKey) == ["1110", "1119", "1120", "1101", "1115", "1114", "1105", "1111", "1102", "1104", "1113", "1118", "1116", "1107", "1117", "1109", "1106", "1108", "1112"])
+ #expect(unwatched.totalSize == 19)
+ let progressing = try await client.fetchMediaPage(
+ contentPath: moviesPath, using: configuration,
+ browseOptions: PlexLibraryBrowseOptions(enabledBooleanFilterIDs: ["inProgress"])
+ )
+ #expect(progressing.items.map(\.ratingKey) == ["1101"])
+ #expect(progressing.totalSize == 1)
+ }
+
+ @Test func relatedExtrasAndPeopleResolveTheirOwnContracts() async throws {
+ let client = PlexAPIClient(session: PlexDebugMockServer.makeSession())
+ let film = try await client.fetchMediaMetadata(ratingKey: "1101", using: configuration)
+ let related = try await client.fetchRelatedHubs(ratingKey: film.ratingKey, using: configuration, count: 1)
+ let hub = try #require(related.first)
+ #expect(hub.more)
+ let page = try await client.fetchMediaPage(contentPath: try #require(hub.key), using: configuration, start: 1, size: 1)
+ #expect(page.items.first?.ratingKey != hub.metadata.first?.ratingKey)
+ let extras = try await client.fetchMediaExtras(ratingKey: film.ratingKey, using: configuration)
+ #expect(extras.count == 1)
+ #expect(extras.first?.type == "clip")
+ #expect(extras.first?.subtype == "trailer")
+ let emptyExtras = try await client.fetchMediaExtras(ratingKey: "1102", using: configuration)
+ #expect(emptyExtras.isEmpty)
+ let personID = try #require(film.roles.first?.tagKey)
+ let person = try await client.fetchPerson(identifier: personID, using: configuration)
+ #expect(person.tag == film.roles.first?.tag)
+ let appearances = try await client.fetchPersonMedia(identifier: personID, using: configuration)
+ #expect(appearances.map(\.ratingKey) == [film.ratingKey])
+ }
+
+ @Test func mockRejectsUnknownRoutesAndWriteMethodsWithoutNetworkForwarding() async throws {
+ let session = PlexDebugMockServer.makeSession()
+ for (path, method, expected) in [
+ ("/library/metadata/1101/refresh", "GET", 404),
+ ("/library/metadata/1101/extras/invalid", "GET", 404),
+ ("/library/metadata/missing", "GET", 404),
+ ("/playlists", "POST", 405),
+ ("/status/sessions/terminate", "GET", 405),
+ ("/video/:/transcode/universal/decision", "GET", 404)
+ ] {
+ var request = URLRequest(url: configuration.serverURL.appending(path: path))
+ request.httpMethod = method
+ let (data, response) = try await session.data(for: request)
+ #expect((response as? HTTPURLResponse)?.statusCode == expected)
+ #expect(String(decoding: data, as: UTF8.self).contains("Mock data does not support"))
+ }
+ let (_, response) = try await session.data(from: URL(string: "https://unhandled.invalid/missing")!)
+ #expect((response as? HTTPURLResponse)?.statusCode == 404)
+ }
+
+ @Test func catalogSourcesAndCountsValidateWithoutCreatingPlaybackSources() throws {
+ let catalog = try PlexMockMediaCatalog.loadDefault()
+ #expect(catalog.records.allSatisfy { !$0.sources.isEmpty && !$0.item.isPlayable })
+ let payload = try PlexMockServerPayload.loadDefault()
+ let artworkPaths = Set(payload.artwork.map(\.path))
+ #expect(artworkPaths.count == payload.artwork.count)
+ for record in catalog.records {
+ let item = record.item
+ let paths = [item.thumb, item.art, item.parentThumb, item.grandparentThumb].compactMap { $0 }
+ #expect(paths.allSatisfy { artworkPaths.contains($0) })
+ }
+ for artwork in payload.artwork {
+ let data = try Data(contentsOf: PlexMockServerResourceLocator.url(for: artwork.resource))
+ #expect(!data.isEmpty)
+ }
+ for album in catalog.records where album.item.type == "album" {
+ let tracks = catalog.children(of: album.item.ratingKey)
+ #expect(album.item.duration == tracks.reduce(0) { $0 + ($1.item.duration ?? 0) })
+ #expect(tracks.allSatisfy { $0.item.parentTitle == album.item.title })
+ }
+ let data = try Data(contentsOf: PlexMockServerResourceLocator.url(for: "media-catalog.json"))
+ var entries = try #require(JSONSerialization.jsonObject(with: data) as? [[String: Any]])
+ entries[0]["relatedIDs"] = ["missing"]
+ let broken = try JSONSerialization.data(withJSONObject: entries)
+ #expect(throws: PlexMockMediaCatalog.CatalogError.self) {
+ try PlexMockMediaCatalog(data: broken)
+ }
+ }
+}
+#endif
diff --git a/PlexBarTests/PlexDescriptionLineBreakTests.swift b/PlexBarTests/PlexDescriptionLineBreakTests.swift
new file mode 100644
index 0000000..8e06c49
--- /dev/null
+++ b/PlexBarTests/PlexDescriptionLineBreakTests.swift
@@ -0,0 +1,42 @@
+import AppKit
+import CoreText
+import Testing
+@testable import PlexBar
+
+@MainActor
+struct PlexDescriptionLineBreakTests {
+ private let summary = "A successful real estate agent living in the shadow of his wealthier older brother has his carefully ordered life upended when the eccentric young man he once mentored through a Big Brother program unexpectedly returns, convinced they're family and refusing to leave."
+
+ @Test(arguments: [180.0, 260.0, 560.0], [13.0, 20.0])
+ func firstLineUsesFullWidthWithoutDroppingText(width: Double, size: Double) throws {
+ let font = NSFont.systemFont(ofSize: size)
+ let result = try #require(PlexDescriptionLineBreak(summary: summary, width: width, font: font))
+ #expect(result.firstLine + result.remainingText == summary)
+ #expect(renderedWidth(result.firstLine, font: font) <= width)
+ let nextWord = try #require(result.remainingText.split(separator: " ").first)
+ #expect(renderedWidth(result.firstLine + nextWord, font: font) > width)
+ }
+
+ @Test(arguments: ["", "A short description.", "First line.\nSecond line."])
+ func fittingTextNeedsNoMoreButton(text: String) {
+ #expect(PlexDescriptionLineBreak(summary: text, width: 300, font: .systemFont(ofSize: 13)) == nil)
+ }
+
+ @Test func resizingChangesTruncationWithoutRetainingPreviousLayout() {
+ let font = NSFont.systemFont(ofSize: 13)
+ #expect(PlexDescriptionLineBreak(summary: summary, width: 260, font: font) != nil)
+ #expect(PlexDescriptionLineBreak(summary: summary, width: 1_200, font: font) == nil)
+ #expect(PlexDescriptionLineBreak(summary: summary, width: 260, font: font) != nil)
+ }
+
+ @Test func unicodeIsPreservedAcrossTheLineBreak() throws {
+ let text = String(repeating: "Café 👩🏽🚀 déjà vu — family stories continue. ", count: 8)
+ let result = try #require(PlexDescriptionLineBreak(summary: text, width: 260, font: .systemFont(ofSize: 13)))
+ #expect(result.firstLine + result.remainingText == text)
+ }
+
+ private func renderedWidth(_ text: String, font: NSFont) -> Double {
+ let line = CTLineCreateWithAttributedString(NSAttributedString(string: text, attributes: [.font: font]))
+ return CTLineGetTypographicBounds(line, nil, nil, nil) - CTLineGetTrailingWhitespaceWidth(line)
+ }
+}
diff --git a/PlexBarTests/PlexDeviceIdentityStoreTests.swift b/PlexBarTests/PlexDeviceIdentityStoreTests.swift
new file mode 100644
index 0000000..5a576be
--- /dev/null
+++ b/PlexBarTests/PlexDeviceIdentityStoreTests.swift
@@ -0,0 +1,86 @@
+import Foundation
+import Testing
+@testable import PlexBar
+
+private final class DeviceIdentityPersistenceStub: PlexDeviceIdentityPersisting, @unchecked Sendable {
+ private let lock = NSLock()
+ private let discardedAccounts: Set
+ private var values: [String: String]
+
+ init(
+ values: [String: String] = [:],
+ discardedAccounts: Set = []
+ ) {
+ self.values = values
+ self.discardedAccounts = discardedAccounts
+ }
+
+ func read(account: String) async -> String? {
+ lock.withLock {
+ values[account]
+ }
+ }
+
+ func write(_ value: String, account: String) async {
+ lock.withLock {
+ guard !discardedAccounts.contains(account) else {
+ return
+ }
+ values[account] = value
+ }
+ }
+
+ func delete(account: String) async {
+ lock.withLock {
+ values[account] = nil
+ }
+ }
+
+ func snapshot() -> [String: String] {
+ lock.lock()
+ defer { lock.unlock() }
+ return values
+ }
+}
+
+struct PlexDeviceIdentityStoreTests {
+ @Test func generatedIdentityIsPersistedAndReloadedExactly() async throws {
+ let persistence = DeviceIdentityPersistenceStub()
+ let store = PlexKeychainDeviceIdentityStore(keychain: persistence)
+
+ let generated = try await store.loadOrCreateIdentity()
+ let reloadedStore = PlexKeychainDeviceIdentityStore(keychain: persistence)
+ let reloaded = try await reloadedStore.loadOrCreateIdentity()
+
+ #expect(reloaded == generated)
+ #expect(persistence.snapshot()[KeychainAccounts.jwtKeyID] == generated.keyID)
+ #expect(
+ persistence.snapshot()[KeychainAccounts.jwtPrivateKey]
+ == generated.privateKeyRepresentation.base64EncodedString()
+ )
+ }
+
+ @Test func incompletePersistedIdentityIsRejected() async {
+ let persistence = DeviceIdentityPersistenceStub(values: [
+ KeychainAccounts.jwtKeyID: "device-key",
+ ])
+ let store = PlexKeychainDeviceIdentityStore(keychain: persistence)
+
+ await #expect(throws: PlexJWTError.self) {
+ _ = try await store.loadOrCreateIdentity()
+ }
+ }
+
+ @Test func failedPersistenceDeletesPartialIdentityAndSurfacesError() async {
+ let persistence = DeviceIdentityPersistenceStub(
+ discardedAccounts: [KeychainAccounts.jwtPrivateKey]
+ )
+ let store = PlexKeychainDeviceIdentityStore(keychain: persistence)
+
+ await #expect(throws: PlexJWTError.self) {
+ _ = try await store.loadOrCreateIdentity()
+ }
+
+ #expect(persistence.snapshot().isEmpty)
+ }
+}
diff --git a/PlexBarTests/PlexDownloadAuthorizationTests.swift b/PlexBarTests/PlexDownloadAuthorizationTests.swift
new file mode 100644
index 0000000..a58e897
--- /dev/null
+++ b/PlexBarTests/PlexDownloadAuthorizationTests.swift
@@ -0,0 +1,294 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+@Suite(.serialized)
+struct PlexDownloadAuthorizationTests {
+ @Test func accountDownloadsEntitlementUsesEffectivePlexPassSubscriptionStates() throws {
+ let activeUser = try decodeUser(subscriptionType: "plexpass", state: "active")
+ let cancelingUser = try decodeUser(
+ subscriptionType: "plexpass",
+ state: "pending_cancellation"
+ )
+ let canceledUser = try decodeUser(subscriptionType: "plexpass", state: "canceled")
+ let remotePassUser = try decodeUser(subscriptionType: "remotewatchpass", state: "active")
+
+ #expect(activeUser.hasDownloadsAccountEntitlement)
+ #expect(cancelingUser.hasDownloadsAccountEntitlement)
+ #expect(!canceledUser.hasDownloadsAccountEntitlement)
+ #expect(!remotePassUser.hasDownloadsAccountEntitlement)
+ }
+
+ @Test func missingSubscriptionsDoesNotInventAnAccountEntitlement() throws {
+ let user = try JSONDecoder().decode(
+ PlexAuthenticatedUser.self,
+ from: Data(#"{"id":42,"username":"test-user"}"#.utf8)
+ )
+
+ #expect(user.subscriptions.isEmpty)
+ #expect(!user.hasDownloadsAccountEntitlement)
+ }
+
+ @Test func currentAccountSubscriptionFeaturesAuthorizeDownloads() throws {
+ let user = try JSONDecoder().decode(
+ PlexAuthenticatedUser.self,
+ from: Data(#"""
+ {
+ "id": 42,
+ "username": "test-user",
+ "subscription": {
+ "active": 1,
+ "status": "Active",
+ "plan": "lifetime",
+ "features": ["downloads-gating", "sync"]
+ },
+ "roles": ["plexpass"],
+ "entitlements": []
+ }
+ """#.utf8)
+ )
+
+ #expect(user.subscription?.active == true)
+ #expect(user.subscription?.features.contains("sync") == true)
+ #expect(user.hasPlexPass)
+ #expect(user.hasDownloadsAccountEntitlement)
+ }
+
+ @Test func grandfatherSyncCapabilityAuthorizesDownloadsWithoutActiveSubscription() throws {
+ let user = try JSONDecoder().decode(
+ PlexAuthenticatedUser.self,
+ from: Data(#"""
+ {
+ "id": 42,
+ "username": "test-user",
+ "subscription": {
+ "active": 0,
+ "status": "Inactive",
+ "features": []
+ },
+ "entitlements": ["grandfather-sync"]
+ }
+ """#.utf8)
+ )
+
+ #expect(!user.hasPlexPass)
+ #expect(user.hasDownloadsAccountEntitlement)
+ }
+
+ @Test func activeSubscriptionWithoutDownloadCapabilityIsNotTreatedAsPlexPass() throws {
+ let user = try JSONDecoder().decode(
+ PlexAuthenticatedUser.self,
+ from: Data(#"""
+ {
+ "id": 42,
+ "username": "test-user",
+ "subscription": {
+ "active": 1,
+ "status": "Active",
+ "plan": "remote-watch",
+ "features": ["remote-watch"]
+ }
+ }
+ """#.utf8)
+ )
+
+ #expect(!user.hasPlexPass)
+ #expect(!user.hasDownloadsAccountEntitlement)
+ }
+
+ @Test func providerCapabilitiesDecodeServerPermissionAndDownloadFlavorIndependently() throws {
+ let endpoints = try providerEndpoints(allowSync: "true", includeDownloadFeature: true)
+
+ #expect(endpoints.serverAllowsSync == true)
+ #expect(endpoints.supportsDownloadSubscriptions)
+ }
+
+ @Test func downloadAuthorizationRequiresEveryPublishedGate() throws {
+ let user = try decodeUser(subscriptionType: "plexpass", state: "active")
+ let noPassUser = try decodeUser(subscriptionType: "plexpass", state: "lapsed")
+ let authorizedEndpoints = try providerEndpoints(
+ allowSync: "true",
+ includeDownloadFeature: true
+ )
+ let deniedEndpoints = try providerEndpoints(
+ allowSync: "false",
+ includeDownloadFeature: true
+ )
+ let unknownPermissionEndpoints = try providerEndpoints(
+ allowSync: nil,
+ includeDownloadFeature: true
+ )
+ let unsupportedProviderEndpoints = try providerEndpoints(
+ allowSync: "true",
+ includeDownloadFeature: false
+ )
+
+ #expect(PlexDownloadAuthorization(
+ user: user,
+ library: downloadLibrary(),
+ providerEndpoints: authorizedEndpoints
+ ).isAuthorized)
+ #expect(!PlexDownloadAuthorization(
+ user: noPassUser,
+ library: downloadLibrary(),
+ providerEndpoints: authorizedEndpoints
+ ).isAuthorized)
+ #expect(!PlexDownloadAuthorization(
+ user: user,
+ library: downloadLibrary(),
+ providerEndpoints: deniedEndpoints
+ ).isAuthorized)
+ #expect(!PlexDownloadAuthorization(
+ user: user,
+ library: downloadLibrary(),
+ providerEndpoints: unknownPermissionEndpoints
+ ).isAuthorized)
+ #expect(!PlexDownloadAuthorization(
+ user: user,
+ library: downloadLibrary(),
+ providerEndpoints: unsupportedProviderEndpoints
+ ).isAuthorized)
+ #expect(!PlexDownloadAuthorization(
+ user: user,
+ library: downloadLibrary(allowSync: false),
+ providerEndpoints: authorizedEndpoints
+ ).isAuthorized)
+ }
+
+ @Test func librarySectionPreservesItsOwnAllowSyncFact() async throws {
+ let session = makeDownloadAuthorizationMockSession { request in
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: ["X-Plex-Container-Total-Size": "1"]
+ ))
+ let data: Data
+ if request.url?.path == "/library/sections/all" {
+ data = Data(#"{"MediaContainer":{"Directory":[{"key":"1","title":"Movies","type":"movie","allowSync":true}]}}"#.utf8)
+ } else {
+ data = Data(#"{"MediaContainer":{"size":1,"totalSize":1,"Metadata":[{"ratingKey":"42","title":"Movie"}]}}"#.utf8)
+ }
+ return (response, data)
+ }
+ let configuration = PlexConnectionConfiguration(
+ serverURL: try #require(URL(string: "https://plex.local:32400")),
+ token: "server-token",
+ clientContext: PlexClientContext(clientIdentifier: "tests")
+ )
+
+ let libraries = try await PlexAPIClient(session: session).fetchLibraries(
+ using: configuration
+ )
+
+ #expect(libraries.first?.allowSync == true)
+ }
+
+ private func decodeUser(
+ subscriptionType: String,
+ state: String
+ ) throws -> PlexAuthenticatedUser {
+ try JSONDecoder().decode(
+ PlexAuthenticatedUser.self,
+ from: Data(#"""
+ {
+ "id": 42,
+ "username": "test-user",
+ "subscriptions": {
+ "subscription": [{
+ "type": "\#(subscriptionType)",
+ "state": "\#(state)",
+ "mode": "recurring",
+ "active": true,
+ "subscribedAt": "2026-08-01T00:00:00Z"
+ }]
+ }
+ }
+ """#.utf8)
+ )
+ }
+
+ private func providerEndpoints(
+ allowSync: String?,
+ includeDownloadFeature: Bool
+ ) throws -> PlexLibraryProviderEndpoints {
+ let allowSyncField = allowSync.map { "\"allowSync\":\($0)," } ?? ""
+ let features = includeDownloadFeature
+ ? #"[{"type":"subscribe","flavor":"download"}]"#
+ : "[]"
+ let data = Data("""
+ {
+ "MediaContainer": {
+ \(allowSyncField)
+ "MediaProvider": [{
+ "identifier": "com.plexapp.plugins.library",
+ "Feature": \(features)
+ }]
+ }
+ }
+ """.utf8)
+ let envelope = try JSONDecoder().decode(PlexMediaProvidersEnvelope.self, from: data)
+ return try envelope.mediaContainer.libraryProviderEndpoints()
+ }
+}
+
+private func downloadLibrary(allowSync: Bool? = true) -> PlexLibrary {
+ PlexLibrary(
+ id: "1",
+ title: "Movies",
+ type: .movie,
+ compositePath: nil,
+ artPath: nil,
+ thumbPath: nil,
+ itemCount: 1,
+ secondaryCount: nil,
+ secondaryCountLabel: nil,
+ updatedAt: nil,
+ scannedAt: nil,
+ contentChangedAt: nil,
+ latestAddedAt: nil,
+ latestItemTitle: nil,
+ allowSync: allowSync
+ )
+}
+
+private func makeDownloadAuthorizationMockSession(
+ handler: @escaping @Sendable (URLRequest) throws -> (HTTPURLResponse, Data)
+) -> URLSession {
+ DownloadAuthorizationURLProtocol.requestHandler = handler
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.protocolClasses = [DownloadAuthorizationURLProtocol.self]
+ return URLSession(configuration: configuration)
+}
+
+private final class DownloadAuthorizationURLProtocol: URLProtocol, @unchecked Sendable {
+ nonisolated(unsafe) static var requestHandler:
+ (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))?
+
+ override class func canInit(with request: URLRequest) -> Bool {
+ true
+ }
+
+ override class func canonicalRequest(for request: URLRequest) -> URLRequest {
+ request
+ }
+
+ override func startLoading() {
+ guard let requestHandler = Self.requestHandler else {
+ client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
+ return
+ }
+
+ do {
+ let (response, data) = try requestHandler(request)
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: data)
+ client?.urlProtocolDidFinishLoading(self)
+ } catch {
+ client?.urlProtocol(self, didFailWithError: error)
+ }
+ }
+
+ override func stopLoading() {}
+}
diff --git a/PlexBarTests/PlexDownloadCreationStoreTests.swift b/PlexBarTests/PlexDownloadCreationStoreTests.swift
new file mode 100644
index 0000000..533fe2e
--- /dev/null
+++ b/PlexBarTests/PlexDownloadCreationStoreTests.swift
@@ -0,0 +1,469 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+@MainActor
+@Suite(.serialized)
+struct PlexDownloadCreationStoreTests {
+ @Test func currentExactAuthorizationSchedulesAndResumesTheTransfer() async throws {
+ let fixture = try DownloadCreationFixture()
+ defer { fixture.removeRoot() }
+
+ let record = try await fixture.store.schedule(
+ fixture.transferRequest(),
+ forLibraryID: fixture.library.id,
+ transferID: fixture.transferID,
+ createdAt: Date(timeIntervalSince1970: 1_777_777_777)
+ )
+
+ #expect(record.id == fixture.transferID)
+ #expect(record.packageIdentity.serverIdentifier == "server-id")
+ #expect(fixture.transferHarness.createdTaskCount == 1)
+ #expect(fixture.transferHarness.resumedTaskIdentifiers == [41])
+ }
+
+ @Test func creationBoundaryUsesDownloadDefaultsInsteadOfStreamingQuality() throws {
+ let fixture = try DownloadCreationFixture()
+ defer { fixture.removeRoot() }
+ let item = try JSONDecoder().decode(PlexMediaItem.self, from: Data(#"""
+ {
+ "ratingKey": "42",
+ "key": "/library/metadata/42",
+ "title": "Movie",
+ "type": "movie",
+ "Media": [{
+ "videoCodec": "hevc",
+ "audioCodec": "aac",
+ "width": 3840,
+ "height": 2160,
+ "bitrate": 30000,
+ "Part": [{"key": "/library/parts/7/file.mkv"}]
+ }]
+ }
+ """#.utf8))
+
+ fixture.settings.localVideoQuality = .original
+ fixture.settings.remoteVideoQuality = .sd1500Kbps
+ fixture.settings.downloadVideoQuality = .fullHD12Mbps
+ let first = try fixture.store.decisionParameters(
+ for: item,
+ source: PlexPlaybackSource(mediaIndex: 0, partIndex: 0),
+ sessionIdentifier: "download-session"
+ )
+
+ fixture.settings.localVideoQuality = .hd2Mbps
+ fixture.settings.remoteVideoQuality = .fourK20Mbps
+ let second = try fixture.store.decisionParameters(
+ for: item,
+ source: PlexPlaybackSource(mediaIndex: 0, partIndex: 0),
+ sessionIdentifier: "download-session"
+ )
+
+ #expect(first == second)
+ #expect(second.videoBitrate == 12_000)
+ #expect(second.videoResolution == "1920x1080")
+ #expect(second.allowsDirectPlay == false)
+ }
+
+ @Test func signOutAndAccountSwitchImmediatelyInvalidateAGrant() async throws {
+ let fixture = try DownloadCreationFixture()
+ defer { fixture.removeRoot() }
+ let grant = try await fixture.store.authorization(forLibraryID: fixture.library.id)
+
+ fixture.authStore.authenticatedUser = nil
+ fixture.settings.clearAuthentication()
+ #expect(!fixture.store.isCurrent(grant))
+ await #expect(throws: PlexDownloadCreationAuthorizationError.authorizationExpired) {
+ _ = try await fixture.store.schedule(
+ fixture.transferRequest(),
+ authorization: grant
+ )
+ }
+
+ fixture.restoreCredentials()
+ fixture.authStore.authenticatedUser = fixture.user(id: 99)
+ #expect(!fixture.store.isCurrent(grant))
+ #expect(fixture.transferHarness.createdTaskCount == 0)
+ }
+
+ @Test func serverConnectionAndLibraryPermissionChangesInvalidateAGrant() async throws {
+ let fixture = try DownloadCreationFixture()
+ defer { fixture.removeRoot() }
+ let grant = try await fixture.store.authorization(forLibraryID: fixture.library.id)
+
+ fixture.connectionStore.activeConnection = PlexResolvedConnection(
+ serverID: "different-server",
+ url: try #require(URL(string: "https://other-plex.local:32400")),
+ kind: .remote,
+ validatedAt: Date()
+ )
+ fixture.settings.selectedServerIdentifier = "different-server"
+ #expect(!fixture.store.isCurrent(grant))
+
+ fixture.restoreServer()
+ fixture.libraryStore.libraries = [fixture.library(allowSync: false)]
+ #expect(!fixture.store.isCurrent(grant))
+ #expect(fixture.transferHarness.createdTaskCount == 0)
+ }
+
+ @Test func everyAccountServerLibraryAndProviderGateIsRequired() async throws {
+ let noPass = try DownloadCreationFixture(userHasPlexPass: false)
+ defer { noPass.removeRoot() }
+ await #expect(throws: PlexDownloadCreationAuthorizationError.accountNotEntitled) {
+ _ = try await noPass.store.authorization(forLibraryID: noPass.library.id)
+ }
+
+ let serverDenied = try DownloadCreationFixture(serverAllowsSync: false)
+ defer { serverDenied.removeRoot() }
+ await #expect(throws: PlexDownloadCreationAuthorizationError.serverDisallowsDownloads) {
+ _ = try await serverDenied.store.authorization(forLibraryID: serverDenied.library.id)
+ }
+
+ let libraryDenied = try DownloadCreationFixture(libraryAllowsSync: false)
+ defer { libraryDenied.removeRoot() }
+ await #expect(throws: PlexDownloadCreationAuthorizationError.libraryDisallowsDownloads) {
+ _ = try await libraryDenied.store.authorization(forLibraryID: libraryDenied.library.id)
+ }
+
+ let providerDenied = try DownloadCreationFixture(providerSupportsDownloads: false)
+ defer { providerDenied.removeRoot() }
+ await #expect(throws: PlexDownloadCreationAuthorizationError.providerDisallowsDownloads) {
+ _ = try await providerDenied.store.authorization(forLibraryID: providerDenied.library.id)
+ }
+ }
+
+ @Test func currentAuthorizationReflectsEveryAdvertisedDownloadGate() async throws {
+ let authorized = try DownloadCreationFixture()
+ defer { authorized.removeRoot() }
+ await authorized.browserStore.loadLibraryProviderCapabilities()
+ #expect(authorized.store.isCurrentlyAuthorized(forLibraryID: authorized.library.id))
+
+ let noPass = try DownloadCreationFixture(userHasPlexPass: false)
+ defer { noPass.removeRoot() }
+ await noPass.browserStore.loadLibraryProviderCapabilities()
+ #expect(!noPass.store.isCurrentlyAuthorized(forLibraryID: noPass.library.id))
+
+ let serverDenied = try DownloadCreationFixture(serverAllowsSync: false)
+ defer { serverDenied.removeRoot() }
+ await serverDenied.browserStore.loadLibraryProviderCapabilities()
+ #expect(!serverDenied.store.isCurrentlyAuthorized(forLibraryID: serverDenied.library.id))
+
+ let libraryDenied = try DownloadCreationFixture(libraryAllowsSync: false)
+ defer { libraryDenied.removeRoot() }
+ await libraryDenied.browserStore.loadLibraryProviderCapabilities()
+ #expect(!libraryDenied.store.isCurrentlyAuthorized(forLibraryID: libraryDenied.library.id))
+
+ let providerDenied = try DownloadCreationFixture(providerSupportsDownloads: false)
+ defer { providerDenied.removeRoot() }
+ await providerDenied.browserStore.loadLibraryProviderCapabilities()
+ #expect(!providerDenied.store.isCurrentlyAuthorized(forLibraryID: providerDenied.library.id))
+ }
+
+ @Test func transferMustMatchTheAuthorizedServerOriginLibraryAndQueueIdentity() async throws {
+ let fixture = try DownloadCreationFixture()
+ defer { fixture.removeRoot() }
+ let grant = try await fixture.store.authorization(forLibraryID: fixture.library.id)
+
+ await #expect(throws: PlexDownloadCreationAuthorizationError.mismatchedTransfer) {
+ _ = try await fixture.store.schedule(
+ fixture.transferRequest(accountID: 99),
+ authorization: grant
+ )
+ }
+ await #expect(throws: PlexDownloadCreationAuthorizationError.mismatchedTransfer) {
+ _ = try await fixture.store.schedule(
+ fixture.transferRequest(serverIdentifier: "different-server"),
+ authorization: grant
+ )
+ }
+ await #expect(throws: PlexDownloadCreationAuthorizationError.mismatchedTransfer) {
+ _ = try await fixture.store.schedule(
+ fixture.transferRequest(serverURL: "https://outside.example:32400"),
+ authorization: grant
+ )
+ }
+ await #expect(throws: PlexDownloadCreationAuthorizationError.mismatchedTransfer) {
+ _ = try await fixture.store.schedule(
+ fixture.transferRequest(librarySectionID: "2"),
+ authorization: grant
+ )
+ }
+ #expect(fixture.transferHarness.createdTaskCount == 0)
+ }
+}
+
+@MainActor
+private final class DownloadCreationFixture {
+ let transferID = UUID(uuidString: "11111111-2222-3333-4444-555555555555")!
+ let rootURL: URL
+ let defaults: UserDefaults
+ let settings: PlexSettingsStore
+ let connectionStore: PlexConnectionStore
+ let libraryStore: PlexLibraryStore
+ let browserStore: PlexBrowserStore
+ let authStore: PlexAuthStore
+ let transferHarness = DownloadCreationTransferHarness()
+ let store: PlexDownloadCreationStore
+
+ private let serverAllowsSync: Bool
+ private let providerSupportsDownloads: Bool
+
+ init(
+ userHasPlexPass: Bool = true,
+ serverAllowsSync: Bool = true,
+ libraryAllowsSync: Bool = true,
+ providerSupportsDownloads: Bool = true
+ ) throws {
+ self.serverAllowsSync = serverAllowsSync
+ self.providerSupportsDownloads = providerSupportsDownloads
+ rootURL = FileManager.default.temporaryDirectory.appendingPathComponent(
+ "PlexBarDownloadCreationTests-\(UUID().uuidString)",
+ isDirectory: true
+ )
+ let suiteName = "PlexBarTests.DownloadCreation.\(UUID().uuidString)"
+ defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+
+ let credentials = PlexStoredCredentials(
+ userToken: "user-token",
+ serverToken: "server-token"
+ )
+ settings = PlexSettingsStore(
+ defaults: defaults,
+ credentialStore: PlexMemoryCredentialStore(credentials: credentials),
+ initialCredentials: credentials
+ )
+ settings.selectedServerIdentifier = "server-id"
+ settings.selectedServerName = "Server"
+ connectionStore = PlexConnectionStore(settings: settings)
+ connectionStore.activeConnection = PlexResolvedConnection(
+ serverID: "server-id",
+ url: try #require(URL(string: "https://plex.local:32400")),
+ kind: .local,
+ validatedAt: Date()
+ )
+
+ let providerData = Self.providerData(
+ serverAllowsSync: serverAllowsSync,
+ providerSupportsDownloads: providerSupportsDownloads
+ )
+ let client = PlexAPIClient(session: Self.session(responseData: providerData))
+ libraryStore = PlexLibraryStore(connectionStore: connectionStore, client: client)
+ libraryStore.libraries = [Self.makeLibrary(allowSync: libraryAllowsSync)]
+ browserStore = PlexBrowserStore(connectionStore: connectionStore, client: client)
+ let sessionStore = PlexSessionStore(connectionStore: connectionStore, client: client)
+ let historyStore = PlexHistoryStore(
+ connectionStore: connectionStore,
+ libraryStore: libraryStore,
+ client: client,
+ startsPolling: false
+ )
+ authStore = PlexAuthStore(
+ settings: settings,
+ connectionStore: connectionStore,
+ sessionStore: sessionStore,
+ historyStore: historyStore,
+ libraryStore: libraryStore,
+ deviceIdentityStore: PlexMemoryDeviceIdentityStore()
+ )
+ authStore.authenticatedUser = Self.makeUser(
+ id: 42,
+ hasPlexPass: userHasPlexPass
+ )
+
+ let handoffStore = PlexDownloadHandoffStore(rootURL: rootURL)
+ let coordinator = PlexDownloadTransferCoordinator(
+ registry: PlexDownloadTransferRegistry(rootURL: rootURL),
+ packageStore: PlexDownloadPackageStore(rootURL: rootURL),
+ handoffStore: handoffStore,
+ session: transferHarness.session
+ )
+ store = PlexDownloadCreationStore(
+ authStore: authStore,
+ connectionStore: connectionStore,
+ libraryStore: libraryStore,
+ browserStore: browserStore,
+ transferCoordinator: coordinator
+ )
+ }
+
+ var library: PlexLibrary {
+ Self.makeLibrary(allowSync: true)
+ }
+
+ func library(allowSync: Bool) -> PlexLibrary {
+ Self.makeLibrary(allowSync: allowSync)
+ }
+
+ func user(id: Int) -> PlexAuthenticatedUser {
+ Self.makeUser(id: id, hasPlexPass: true)
+ }
+
+ func restoreCredentials() {
+ settings.userToken = "user-token"
+ settings.serverToken = "server-token"
+ }
+
+ func restoreServer() {
+ settings.selectedServerIdentifier = "server-id"
+ connectionStore.activeConnection = PlexResolvedConnection(
+ serverID: "server-id",
+ url: URL(string: "https://plex.local:32400")!,
+ kind: .local,
+ validatedAt: Date()
+ )
+ }
+
+ func transferRequest(
+ accountID: Int = 42,
+ serverIdentifier: String = "server-id",
+ serverURL: String = "https://plex.local:32400",
+ librarySectionID: String = "1"
+ ) -> PlexDownloadTransferRequest {
+ let identity = PlexDownloadPackageIdentity(
+ packageID: UUID(uuidString: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")!,
+ accountID: accountID,
+ serverIdentifier: serverIdentifier,
+ queueID: 7,
+ queueItemID: 11,
+ metadataKey: "/library/metadata/42",
+ ratingKey: "42"
+ )
+ var request = URLRequest(
+ url: URL(string: "\(serverURL)/downloadQueue/7/item/11/media")!
+ )
+ request.httpMethod = "GET"
+ request.setValue("server-token", forHTTPHeaderField: "X-Plex-Token")
+ return PlexDownloadTransferRequest(
+ packageIdentity: identity,
+ title: "Movie",
+ mediaType: "movie",
+ decisionData: Data(#"{"MediaContainer":{"allowSync":"1","Metadata":[{"ratingKey":"42","key":"/library/metadata/42","librarySectionID":"\#(librarySectionID)","title":"Movie","type":"movie","Media":[]}]}}"#.utf8),
+ mediaFileExtension: "mp4",
+ contentType: "video/mp4",
+ request: request
+ )
+ }
+
+ func removeRoot() {
+ try? FileManager.default.removeItem(at: rootURL)
+ }
+
+ private static func makeUser(
+ id: Int,
+ hasPlexPass: Bool
+ ) -> PlexAuthenticatedUser {
+ PlexAuthenticatedUser(
+ id: id,
+ username: "test-user",
+ title: nil,
+ email: nil,
+ thumb: nil,
+ friendlyName: nil,
+ subscriptions: hasPlexPass ? [PlexUserSubscription(
+ type: "plexpass",
+ state: "active",
+ mode: "recurring",
+ active: true,
+ subscribedAt: nil
+ )] : []
+ )
+ }
+
+ private static func makeLibrary(allowSync: Bool) -> PlexLibrary {
+ PlexLibrary(
+ id: "1",
+ title: "Movies",
+ type: .movie,
+ compositePath: nil,
+ artPath: nil,
+ thumbPath: nil,
+ itemCount: 1,
+ secondaryCount: nil,
+ secondaryCountLabel: nil,
+ updatedAt: nil,
+ scannedAt: nil,
+ contentChangedAt: nil,
+ latestAddedAt: nil,
+ latestItemTitle: nil,
+ allowSync: allowSync
+ )
+ }
+
+ private static func providerData(
+ serverAllowsSync: Bool,
+ providerSupportsDownloads: Bool
+ ) -> Data {
+ let features = providerSupportsDownloads
+ ? #"[{"type":"subscribe","flavor":"download"}]"#
+ : "[]"
+ return Data(#"{"MediaContainer":{"allowSync":\#(serverAllowsSync),"MediaProvider":[{"identifier":"com.plexapp.plugins.library","Feature":\#(features)}]}}"#.utf8)
+ }
+
+ private static func session(responseData: Data) -> URLSession {
+ DownloadCreationURLProtocol.responseData = responseData
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.protocolClasses = [DownloadCreationURLProtocol.self]
+ return URLSession(configuration: configuration)
+ }
+}
+
+private final class DownloadCreationTransferHarness: @unchecked Sendable {
+ private let lock = NSLock()
+ private var createdCount = 0
+ private var resumed: [Int] = []
+ private let stream = AsyncStream.makeStream(of: PlexDownloadTransferEvent.self)
+
+ var createdTaskCount: Int {
+ lock.withLock { createdCount }
+ }
+
+ var resumedTaskIdentifiers: [Int] {
+ lock.withLock { resumed }
+ }
+
+ var session: PlexDownloadTransferSession {
+ PlexDownloadTransferSession(
+ events: stream.stream,
+ createTask: { [weak self] _, _ in
+ self?.lock.withLock {
+ self?.createdCount += 1
+ }
+ return 41
+ },
+ tasks: { [] },
+ resumeTask: { [weak self] identifier in
+ self?.lock.withLock {
+ self?.resumed.append(identifier)
+ }
+ },
+ cancelTask: { _ in }
+ )
+ }
+}
+
+private final class DownloadCreationURLProtocol: URLProtocol, @unchecked Sendable {
+ nonisolated(unsafe) static var responseData = Data()
+
+ override class func canInit(with request: URLRequest) -> Bool { true }
+ override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
+
+ override func startLoading() {
+ guard let url = request.url,
+ let response = HTTPURLResponse(
+ url: url,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: ["Content-Type": "application/json"]
+ ) else {
+ client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
+ return
+ }
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: Self.responseData)
+ client?.urlProtocolDidFinishLoading(self)
+ }
+
+ override func stopLoading() {}
+}
diff --git a/PlexBarTests/PlexDownloadHandoffStoreTests.swift b/PlexBarTests/PlexDownloadHandoffStoreTests.swift
new file mode 100644
index 0000000..4536e7c
--- /dev/null
+++ b/PlexBarTests/PlexDownloadHandoffStoreTests.swift
@@ -0,0 +1,119 @@
+import Foundation
+import Testing
+@testable import PlexBar
+
+@Suite(.serialized)
+struct PlexDownloadHandoffStoreTests {
+ @Test func atomicallyAcceptsAndRestoresACompletedHTTPDownload() throws {
+ let fixture = try HandoffFixture()
+ defer { fixture.remove() }
+ let mediaData = Data("completed-media".utf8)
+ let temporaryURL = try fixture.makeFile(data: mediaData)
+
+ let result = fixture.store.accept(
+ temporaryFileURL: temporaryURL,
+ transferID: fixture.transferID,
+ response: fixture.response()
+ )
+ let handoff = try result.get()
+
+ #expect(!FileManager.default.fileExists(atPath: temporaryURL.path))
+ #expect(handoff.manifest.transferID == fixture.transferID)
+ #expect(handoff.manifest.statusCode == 200)
+ #expect(handoff.manifest.contentType == "video/mp4")
+ #expect(handoff.manifest.suggestedFileExtension == "mp4")
+ #expect(try Data(contentsOf: handoff.mediaURL) == mediaData)
+ #expect(try fixture.store.handoff(for: fixture.transferID).get() == handoff)
+ }
+
+ @Test func rejectsErrorResponsesAndSymbolicLinksWithoutConsumingTheSource() throws {
+ let fixture = try HandoffFixture()
+ defer { fixture.remove() }
+ let temporaryURL = try fixture.makeFile(data: Data("error-page".utf8))
+
+ let statusResult = fixture.store.accept(
+ temporaryFileURL: temporaryURL,
+ transferID: fixture.transferID,
+ response: fixture.response(statusCode: 401)
+ )
+ #expect(statusResult == .failure(.serverStatus(401)))
+ #expect(FileManager.default.fileExists(atPath: temporaryURL.path))
+
+ let linkURL = fixture.rootURL.appendingPathComponent("download-link")
+ try FileManager.default.createSymbolicLink(
+ at: linkURL,
+ withDestinationURL: temporaryURL
+ )
+ let linkResult = fixture.store.accept(
+ temporaryFileURL: linkURL,
+ transferID: fixture.transferID,
+ response: fixture.response()
+ )
+ #expect(linkResult == .failure(.invalidTemporaryFile))
+ #expect(FileManager.default.fileExists(atPath: temporaryURL.path))
+ }
+
+ @Test func reconciliationRemovesOnlyPrivateStagingAndOrphanHandoffs() throws {
+ let fixture = try HandoffFixture()
+ defer { fixture.remove() }
+ let validID = fixture.transferID
+ let orphanID = UUID()
+ _ = try fixture.store.accept(
+ temporaryFileURL: fixture.makeFile(data: Data("valid".utf8)),
+ transferID: validID,
+ response: fixture.response()
+ ).get()
+ _ = try fixture.store.accept(
+ temporaryFileURL: fixture.makeFile(data: Data("orphan".utf8)),
+ transferID: orphanID,
+ response: fixture.response()
+ ).get()
+ let incomingURL = fixture.rootURL.appendingPathComponent("Incoming", isDirectory: true)
+ let stagingURL = incomingURL.appendingPathComponent(".staging-abandoned", isDirectory: true)
+ try FileManager.default.createDirectory(at: stagingURL, withIntermediateDirectories: false)
+ let unrelatedURL = incomingURL.appendingPathComponent("keep-me")
+ try Data("unrelated".utf8).write(to: unrelatedURL)
+
+ let removed = try fixture.store.reconcile(validTransferIDs: [validID])
+
+ #expect(removed == 2)
+ #expect(try fixture.store.handoff(for: validID).get() != nil)
+ #expect(try fixture.store.handoff(for: orphanID).get() == nil)
+ #expect(FileManager.default.fileExists(atPath: unrelatedURL.path))
+ }
+}
+
+private struct HandoffFixture {
+ let rootURL: URL
+ let transferID = UUID(uuidString: "8AE467A6-15C8-4AF9-8C9B-71EE9F6F387D")!
+ let store: PlexDownloadHandoffStore
+
+ init() throws {
+ rootURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent("PlexDownloadHandoffStoreTests-\(UUID().uuidString)", isDirectory: true)
+ store = PlexDownloadHandoffStore(rootURL: rootURL)
+ try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true)
+ }
+
+ func makeFile(data: Data) throws -> URL {
+ let url = rootURL.appendingPathComponent("temporary-\(UUID().uuidString)")
+ try data.write(to: url)
+ return url
+ }
+
+ func response(statusCode: Int = 200) -> HTTPURLResponse {
+ HTTPURLResponse(
+ url: URL(string: "https://plex.test/downloadQueue/7/item/11/media")!,
+ statusCode: statusCode,
+ httpVersion: "HTTP/2",
+ headerFields: [
+ "Content-Type": "video/mp4",
+ "Content-Disposition": "attachment; filename=episode.mp4",
+ ]
+ )!
+ }
+
+ func remove() {
+ try? FileManager.default.removeItem(at: rootURL)
+ }
+}
diff --git a/PlexBarTests/PlexDownloadJobRegistryTests.swift b/PlexBarTests/PlexDownloadJobRegistryTests.swift
new file mode 100644
index 0000000..e4a4f50
--- /dev/null
+++ b/PlexBarTests/PlexDownloadJobRegistryTests.swift
@@ -0,0 +1,134 @@
+import Foundation
+import Testing
+@testable import PlexBar
+
+@Suite
+struct PlexDownloadJobRegistryTests {
+ @Test func durableJobAndOfflineProgressRoundTripWithoutConnectionSecrets() async throws {
+ let rootURL = FileManager.default.temporaryDirectory.appendingPathComponent(
+ "PlexDownloadJobRegistryTests-\(UUID().uuidString)",
+ isDirectory: true
+ )
+ defer { try? FileManager.default.removeItem(at: rootURL) }
+
+ let identity = PlexDownloadPackageIdentity(
+ packageID: UUID(uuidString: "AD460C2A-9E97-48CB-BC84-57B03BFB4128")!,
+ accountID: 9,
+ serverIdentifier: "server-id",
+ queueID: 7,
+ queueItemID: 11,
+ metadataKey: "/library/metadata/42",
+ ratingKey: "42"
+ )
+ let job = PlexDownloadJob(
+ id: identity.packageID,
+ accountID: 9,
+ packageIdentity: identity,
+ libraryID: "1",
+ title: "Episode",
+ mediaType: "episode",
+ source: PlexPlaybackSource(mediaIndex: 0, partIndex: 0),
+ decisionParameters: PlexDownloadDecisionParameters(
+ mediaPath: identity.metadataKey,
+ mediaIndex: 0,
+ partIndex: 0,
+ deliveryProtocol: .http,
+ allowsDirectPlay: true,
+ sessionIdentifier: "download-session"
+ ),
+ createdAt: Date(timeIntervalSince1970: 1_777_777_777),
+ updatedAt: Date(timeIntervalSince1970: 1_777_777_778),
+ state: .paused,
+ serverPreparationProgress: 1,
+ transferID: UUID(uuidString: "23A8B289-579D-4E40-B23D-B27035BD930A"),
+ errorMessage: nil
+ )
+ let jobRegistry = PlexDownloadJobRegistry(rootURL: rootURL)
+ try await jobRegistry.save(job)
+
+ let restoredJobs = try await PlexDownloadJobRegistry(rootURL: rootURL).jobs()
+ #expect(restoredJobs == [job])
+
+ let rawRegistry = try String(
+ contentsOf: rootURL.appendingPathComponent("Jobs/registry.json"),
+ encoding: .utf8
+ )
+ #expect(!rawRegistry.contains("X-Plex-Token"))
+ #expect(!rawRegistry.contains("https://"))
+
+ let playback = PlexOfflinePlaybackRecord(
+ packageID: identity.packageID,
+ accountID: 9,
+ serverIdentifier: identity.serverIdentifier,
+ ratingKey: identity.ratingKey,
+ baselineViewOffset: 12_000,
+ baselineViewCount: 0,
+ position: 44_000,
+ duration: 60_000,
+ state: .paused,
+ updatedAt: Date(timeIntervalSince1970: 1_777_777_779),
+ needsSync: true
+ )
+ let playbackRegistry = PlexOfflinePlaybackRegistry(rootURL: rootURL)
+ try await playbackRegistry.save(playback)
+
+ let restoredPlayback = try await PlexOfflinePlaybackRegistry(rootURL: rootURL)
+ .record(for: identity.packageID)
+ #expect(restoredPlayback == playback)
+ }
+
+ @Test func multipartJoinSelectionSurvivesJobPersistence() async throws {
+ let rootURL = FileManager.default.temporaryDirectory.appendingPathComponent(
+ "PlexDownloadJobRegistryTests-\(UUID().uuidString)",
+ isDirectory: true
+ )
+ defer { try? FileManager.default.removeItem(at: rootURL) }
+
+ let identity = PlexDownloadPackageIdentity(
+ accountID: 9,
+ serverIdentifier: "server-id",
+ queueID: 7,
+ queueItemID: 11,
+ metadataKey: "/library/metadata/42",
+ ratingKey: "42"
+ )
+ let source = PlexPlaybackSource(mediaIndex: 1, partIndex: -1)
+ let decisionParameters = PlexDownloadDecisionParameters(
+ mediaPath: identity.metadataKey,
+ mediaIndex: source.mediaIndex,
+ partIndex: source.partIndex,
+ deliveryProtocol: .http,
+ allowsDirectPlay: false,
+ allowsDirectStream: false,
+ allowsDirectStreamAudio: false,
+ sessionIdentifier: "multipart-download-session"
+ )
+ let job = PlexDownloadJob(
+ id: identity.packageID,
+ accountID: 9,
+ packageIdentity: identity,
+ libraryID: "1",
+ title: "Multipart Movie",
+ mediaType: "movie",
+ source: source,
+ decisionParameters: decisionParameters,
+ createdAt: Date(timeIntervalSince1970: 1_777_777_777),
+ updatedAt: Date(timeIntervalSince1970: 1_777_777_778),
+ state: .waitingForServer,
+ serverPreparationProgress: nil,
+ transferID: nil,
+ errorMessage: nil
+ )
+
+ let registry = PlexDownloadJobRegistry(rootURL: rootURL)
+ try await registry.save(job)
+
+ let restored = try #require(
+ try await PlexDownloadJobRegistry(rootURL: rootURL).jobs().first
+ )
+ #expect(restored.source == source)
+ #expect(restored.decisionParameters == decisionParameters)
+ #expect(restored.source.partIndex == -1)
+ #expect(restored.decisionParameters.partIndex == -1)
+ }
+}
diff --git a/PlexBarTests/PlexDownloadPackageStoreTests.swift b/PlexBarTests/PlexDownloadPackageStoreTests.swift
new file mode 100644
index 0000000..4da7c1b
--- /dev/null
+++ b/PlexBarTests/PlexDownloadPackageStoreTests.swift
@@ -0,0 +1,409 @@
+import Foundation
+import Testing
+@testable import PlexBar
+
+@Suite(.serialized)
+struct PlexDownloadPackageStoreTests {
+ @Test func publishesOnlyACompleteValidatedPackage() async throws {
+ let fixture = try Fixture()
+ defer { fixture.remove() }
+ let sourceData = Data("native-offline-media".utf8)
+ let sourceURL = try fixture.makeDownloadedFile(data: sourceData)
+ let completedAt = Date(timeIntervalSince1970: 1_788_134_400)
+
+ let package = try await fixture.store.publish(
+ identity: fixture.identity,
+ title: "The Episode",
+ mediaType: "episode",
+ decisionData: fixture.decisionData,
+ downloadedFileURL: sourceURL,
+ mediaFileExtension: ".mp4",
+ contentType: "video/mp4",
+ completedAt: completedAt
+ )
+
+ #expect(!FileManager.default.fileExists(atPath: sourceURL.path))
+ #expect(package.id == fixture.identity.packageID)
+ #expect(package.manifest.schemaVersion == 2)
+ #expect(package.manifest.identity.accountID == 9)
+ #expect(package.packageURL.lastPathComponent == "\(fixture.identity.packageID.uuidString).plexdownload")
+ #expect(package.manifest.title == "The Episode")
+ #expect(package.manifest.mediaFileName == "media.mp4")
+ #expect(package.manifest.mediaByteCount == Int64(sourceData.count))
+ #expect(package.manifest.completedAt == completedAt)
+ #expect(try Data(contentsOf: package.mediaURL) == sourceData)
+ #expect(try Data(contentsOf: package.decisionURL) == fixture.decisionData)
+
+ let reconciliation = try await fixture.store.reconcile()
+ #expect(reconciliation.packages == [package])
+ #expect(reconciliation.integrityIssues.isEmpty)
+ #expect(reconciliation.removedStagingPackageCount == 0)
+ }
+
+ @Test func rejectsInvalidOrMismatchedDecisionWithoutPublishing() async throws {
+ let fixture = try Fixture()
+ defer { fixture.remove() }
+ let invalidSource = try fixture.makeDownloadedFile(data: Data("media".utf8))
+
+ await #expect(throws: PlexDownloadPackageStoreError.self) {
+ _ = try await fixture.store.publish(
+ identity: fixture.identity,
+ title: "Episode",
+ mediaType: "episode",
+ decisionData: Data(#"{"MediaContainer":{"Metadata":[]}}"#.utf8),
+ downloadedFileURL: invalidSource,
+ mediaFileExtension: "mp4",
+ contentType: "video/mp4"
+ )
+ }
+
+ #expect(FileManager.default.fileExists(atPath: invalidSource.path))
+ let reconciliation = try await fixture.store.reconcile()
+ #expect(reconciliation.packages.isEmpty)
+ #expect(reconciliation.integrityIssues.isEmpty)
+ }
+
+ @Test func rejectsEmptyMediaWithoutPublishing() async throws {
+ let fixture = try Fixture()
+ defer { fixture.remove() }
+ let sourceURL = try fixture.makeDownloadedFile(data: Data())
+
+ await #expect(throws: PlexDownloadPackageStoreError.invalidDownloadedFile) {
+ _ = try await fixture.store.publish(
+ identity: fixture.identity,
+ title: "Episode",
+ mediaType: "episode",
+ decisionData: fixture.decisionData,
+ downloadedFileURL: sourceURL,
+ mediaFileExtension: "mp4",
+ contentType: "video/mp4"
+ )
+ }
+
+ let reconciliation = try await fixture.store.reconcile()
+ #expect(reconciliation.packages.isEmpty)
+ #expect(reconciliation.integrityIssues.isEmpty)
+ }
+
+ @Test func rejectsMediaMissingAServerPromisedEmbeddedSubtitle() async throws {
+ let fixture = try Fixture()
+ defer { fixture.remove() }
+ let sourceURL = try fixture.makeDownloadedFile(data: Data("not-an-mp4".utf8))
+
+ await #expect(throws: PlexDownloadPackageStoreError.missingEmbeddedSubtitle) {
+ _ = try await fixture.store.publish(
+ identity: fixture.identity,
+ title: "Episode",
+ mediaType: "episode",
+ decisionData: fixture.decisionWithEmbeddedSubtitleData,
+ downloadedFileURL: sourceURL,
+ mediaFileExtension: "mp4",
+ contentType: "video/mp4"
+ )
+ }
+
+ let reconciliation = try await fixture.store.reconcile()
+ #expect(reconciliation.packages.isEmpty)
+ #expect(reconciliation.integrityIssues.isEmpty)
+ }
+
+ @Test func replacementPublishesOnePackageWithTheNewMedia() async throws {
+ let fixture = try Fixture()
+ defer { fixture.remove() }
+ let originalData = Data("original-media".utf8)
+ let replacementData = Data("replacement-media".utf8)
+
+ _ = try await fixture.publish(mediaData: originalData, title: "Original")
+ let replacement = try await fixture.publish(
+ mediaData: replacementData,
+ title: "Replacement"
+ )
+
+ #expect(replacement.manifest.title == "Replacement")
+ #expect(try Data(contentsOf: replacement.mediaURL) == replacementData)
+ let reconciliation = try await fixture.store.reconcile()
+ #expect(reconciliation.packages.count == 1)
+ #expect(reconciliation.packages.first?.id == fixture.identity.packageID)
+ #expect(reconciliation.integrityIssues.isEmpty)
+ }
+
+ @Test func failedReplacementPreservesThePublishedPackage() async throws {
+ let fixture = try Fixture()
+ defer { fixture.remove() }
+ let originalData = Data("original-media".utf8)
+ let original = try await fixture.publish(mediaData: originalData, title: "Original")
+ let failedSource = try fixture.makeDownloadedFile(data: Data("failed-media".utf8))
+
+ await #expect(throws: PlexDownloadPackageStoreError.self) {
+ _ = try await fixture.store.publish(
+ identity: fixture.identity,
+ title: "Replacement",
+ mediaType: "episode",
+ decisionData: Data("not-json".utf8),
+ downloadedFileURL: failedSource,
+ mediaFileExtension: "mp4",
+ contentType: "video/mp4"
+ )
+ }
+
+ let preserved = try #require(try await fixture.store.package(
+ withID: fixture.identity.packageID
+ ))
+ #expect(preserved.manifest.title == original.manifest.title)
+ #expect(try Data(contentsOf: preserved.mediaURL) == originalData)
+ }
+
+ @Test func reconciliationPurgesStagingButReportsCorruptionWithoutDeletingIt() async throws {
+ let fixture = try Fixture()
+ defer { fixture.remove() }
+ let package = try await fixture.publish(
+ mediaData: Data("media".utf8),
+ title: "Episode"
+ )
+ try Data("tampered-media-is-larger".utf8).write(to: package.mediaURL)
+ let stagingURL = package.packageURL.deletingLastPathComponent()
+ .appendingPathComponent(".staging-abandoned", isDirectory: true)
+ try FileManager.default.createDirectory(at: stagingURL, withIntermediateDirectories: false)
+
+ let reconciliation = try await fixture.store.reconcile()
+
+ #expect(reconciliation.packages.isEmpty)
+ #expect(reconciliation.removedStagingPackageCount == 1)
+ #expect(reconciliation.integrityIssues.count == 1)
+ #expect(reconciliation.integrityIssues.first?.reason == .mediaSizeMismatch(
+ expected: 5,
+ actual: 24
+ ))
+ #expect(FileManager.default.fileExists(atPath: package.packageURL.path))
+ #expect(!FileManager.default.fileExists(atPath: stagingURL.path))
+ await #expect(throws: PlexDownloadPackageStoreError.self) {
+ _ = try await fixture.store.package(withID: fixture.identity.packageID)
+ }
+ }
+
+ @Test func removalIsExactAndIdempotent() async throws {
+ let fixture = try Fixture()
+ defer { fixture.remove() }
+ _ = try await fixture.publish(mediaData: Data("media".utf8), title: "Episode")
+
+ try await fixture.store.removePackage(withID: fixture.identity.packageID)
+ try await fixture.store.removePackage(withID: fixture.identity.packageID)
+
+ #expect(try await fixture.store.package(withID: fixture.identity.packageID) == nil)
+ }
+
+ @Test func artworkAndOfflineMetadataRemainPartOfTheValidatedPackage() async throws {
+ let fixture = try Fixture()
+ defer { fixture.remove() }
+ let artworkData = Data("validated-poster-bytes".utf8)
+ _ = try await fixture.publish(
+ mediaData: Data("media".utf8),
+ title: "The Episode"
+ )
+
+ let package = try await fixture.store.installArtwork(
+ artworkData,
+ for: fixture.identity.packageID
+ )
+ let media = try #require(try await fixture.store.offlineMedia().first)
+
+ #expect(package.manifest.artworkFileName == PlexDownloadPackageStore.artworkFileName)
+ #expect(try Data(contentsOf: #require(package.artworkURL)) == artworkData)
+ #expect(media.package == package)
+ #expect(media.item.ratingKey == fixture.identity.ratingKey)
+ #expect(media.item.key == fixture.identity.metadataKey)
+ #expect(media.item.title == "Episode")
+
+ let reconciliation = try await fixture.store.reconcile()
+ #expect(reconciliation.packages == [package])
+ #expect(reconciliation.integrityIssues.isEmpty)
+ }
+
+ @Test func exactOfflineMediaLookupRevalidatesThePackage() async throws {
+ let fixture = try Fixture()
+ defer { fixture.remove() }
+ let package = try await fixture.publish(
+ mediaData: Data("media".utf8),
+ title: "The Episode"
+ )
+
+ let media = try #require(try await fixture.store.offlineMedia(
+ withID: fixture.identity.packageID
+ ))
+ #expect(media.package == package)
+ #expect(media.item.ratingKey == fixture.identity.ratingKey)
+
+ try Data().write(to: package.mediaURL)
+
+ await #expect(throws: PlexDownloadPackageStoreError.invalidPackage) {
+ _ = try await fixture.store.offlineMedia(withID: fixture.identity.packageID)
+ }
+ }
+
+ @Test func offlineMediaLookupIsScopedToTheExactAccountAndServer() async throws {
+ let fixture = try Fixture()
+ defer { fixture.remove() }
+ let package = try await fixture.publish(
+ mediaData: Data("media".utf8),
+ title: "The Episode"
+ )
+
+ let owned = try await fixture.store.offlineMedia(
+ accountID: 9,
+ serverIdentifier: "server-id"
+ )
+ let wrongAccount = try await fixture.store.offlineMedia(
+ accountID: 10,
+ serverIdentifier: "server-id"
+ )
+ let wrongServer = try await fixture.store.offlineMedia(
+ accountID: 9,
+ serverIdentifier: "other-server"
+ )
+
+ #expect(owned.map(\.id) == [package.id])
+ #expect(wrongAccount.isEmpty)
+ #expect(wrongServer.isEmpty)
+ #expect(try await fixture.store.offlineMedia(
+ withID: package.id,
+ accountID: 9,
+ serverIdentifier: "server-id"
+ )?.id == package.id)
+ #expect(try await fixture.store.offlineMedia(
+ withID: package.id,
+ accountID: 10,
+ serverIdentifier: "server-id"
+ ) == nil)
+ }
+
+ @Test func legacyUnownedIdentityFailsClosedWithoutPublishing() async throws {
+ let fixture = try Fixture()
+ defer { fixture.remove() }
+ let identity = try JSONDecoder().decode(
+ PlexDownloadPackageIdentity.self,
+ from: Data(#"{"packageID":"37A0DC71-EB7D-4F12-8C62-171924BF6136","serverIdentifier":"server-id","queueID":7,"queueItemID":11,"metadataKey":"/library/metadata/42","ratingKey":"42"}"#.utf8)
+ )
+
+ #expect(identity.accountID == nil)
+ await #expect(throws: PlexDownloadPackageStoreError.invalidIdentity) {
+ try await fixture.store.validateMetadata(
+ identity: identity,
+ title: "Episode",
+ decisionData: fixture.decisionData,
+ mediaFileExtension: "mp4"
+ )
+ }
+ #expect(try await fixture.store.reconcile().packages.isEmpty)
+ }
+
+ @Test func rejectsUnsafeIdentityExtensionsAndSymbolicLinkSources() async throws {
+ let fixture = try Fixture()
+ defer { fixture.remove() }
+ let targetURL = try fixture.makeDownloadedFile(data: Data("media".utf8))
+ let symbolicLinkURL = fixture.rootURL.appendingPathComponent("media-link")
+ try FileManager.default.createSymbolicLink(
+ at: symbolicLinkURL,
+ withDestinationURL: targetURL
+ )
+
+ await #expect(throws: PlexDownloadPackageStoreError.self) {
+ _ = try await fixture.store.publish(
+ identity: fixture.identity,
+ title: "Episode",
+ mediaType: "episode",
+ decisionData: fixture.decisionData,
+ downloadedFileURL: symbolicLinkURL,
+ mediaFileExtension: "mp4",
+ contentType: "video/mp4"
+ )
+ }
+ await #expect(throws: PlexDownloadPackageStoreError.self) {
+ _ = try await fixture.store.publish(
+ identity: fixture.identity,
+ title: "Episode",
+ mediaType: "episode",
+ decisionData: fixture.decisionData,
+ downloadedFileURL: targetURL,
+ mediaFileExtension: "../../movie",
+ contentType: "video/mp4"
+ )
+ }
+
+ let invalidIdentity = PlexDownloadPackageIdentity(
+ accountID: 9,
+ serverIdentifier: "server-id",
+ queueID: 7,
+ queueItemID: 11,
+ metadataKey: "/library/metadata/../outside",
+ ratingKey: "42"
+ )
+ await #expect(throws: PlexDownloadPackageStoreError.self) {
+ _ = try await fixture.store.publish(
+ identity: invalidIdentity,
+ title: "Episode",
+ mediaType: "episode",
+ decisionData: fixture.decisionData,
+ downloadedFileURL: targetURL,
+ mediaFileExtension: "mp4",
+ contentType: "video/mp4"
+ )
+ }
+
+ #expect(FileManager.default.fileExists(atPath: targetURL.path))
+ let reconciliation = try await fixture.store.reconcile()
+ #expect(reconciliation.packages.isEmpty)
+ #expect(reconciliation.integrityIssues.isEmpty)
+ }
+}
+
+private struct Fixture {
+ let rootURL: URL
+ let store: PlexDownloadPackageStore
+ let identity = PlexDownloadPackageIdentity(
+ packageID: UUID(uuidString: "37A0DC71-EB7D-4F12-8C62-171924BF6136")!,
+ accountID: 9,
+ serverIdentifier: "server-id",
+ queueID: 7,
+ queueItemID: 11,
+ metadataKey: "/library/metadata/42",
+ ratingKey: "42"
+ )
+
+ init() throws {
+ rootURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent("PlexDownloadPackageStoreTests-\(UUID().uuidString)", isDirectory: true)
+ store = PlexDownloadPackageStore(rootURL: rootURL)
+ }
+
+ var decisionData: Data {
+ Data(#"{"MediaContainer":{"allowSync":"1","Metadata":[{"ratingKey":"42","key":"/library/metadata/42","title":"Episode","type":"episode","Media":[]}]}}"#.utf8)
+ }
+
+ var decisionWithEmbeddedSubtitleData: Data {
+ Data(#"{"MediaContainer":{"allowSync":"1","Metadata":[{"ratingKey":"42","key":"/library/metadata/42","title":"Episode","type":"episode","Media":[{"Part":[{"Stream":[{"streamType":3,"codec":"mov_text","selected":true,"decision":"transcode","location":"embedded"}]}]}]}]}}"#.utf8)
+ }
+
+ func makeDownloadedFile(data: Data) throws -> URL {
+ try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true)
+ let url = rootURL.appendingPathComponent("download-\(UUID().uuidString).tmp")
+ try data.write(to: url)
+ return url
+ }
+
+ func publish(mediaData: Data, title: String) async throws -> PlexDownloadPackage {
+ let sourceURL = try makeDownloadedFile(data: mediaData)
+ return try await store.publish(
+ identity: identity,
+ title: title,
+ mediaType: "episode",
+ decisionData: decisionData,
+ downloadedFileURL: sourceURL,
+ mediaFileExtension: "mp4",
+ contentType: "video/mp4"
+ )
+ }
+
+ func remove() {
+ try? FileManager.default.removeItem(at: rootURL)
+ }
+}
diff --git a/PlexBarTests/PlexDownloadPreferencesTests.swift b/PlexBarTests/PlexDownloadPreferencesTests.swift
new file mode 100644
index 0000000..1561255
--- /dev/null
+++ b/PlexBarTests/PlexDownloadPreferencesTests.swift
@@ -0,0 +1,226 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+struct PlexDownloadPreferencesTests {
+ @Test func originalVideoPreservesSourceQualityAndUsesSelectableSubtitles() throws {
+ let item = try decodeDownloadItem(#"""
+ {
+ "ratingKey": "42",
+ "key": "/library/metadata/42",
+ "title": "Movie",
+ "type": "movie",
+ "Media": [{
+ "videoCodec": "hevc",
+ "audioCodec": "aac",
+ "width": 3840,
+ "height": 2160,
+ "bitrate": 18000,
+ "Part": [{"key": "/library/parts/7/file.mkv"}]
+ }]
+ }
+ """#)
+
+ let decision = try PlexDownloadPreferences.default.decisionParameters(
+ for: item,
+ source: PlexPlaybackSource(mediaIndex: 0, partIndex: 0),
+ sessionIdentifier: "download-session"
+ )
+
+ #expect(decision.mediaPath == "/library/metadata/42")
+ #expect(decision.deliveryProtocol == .http)
+ #expect(decision.allowsDirectPlay == true)
+ #expect(decision.allowsDirectStream == true)
+ #expect(decision.allowsDirectStreamAudio == true)
+ #expect(decision.videoQuality == 99)
+ #expect(decision.videoBitrate == 18_000)
+ #expect(decision.videoResolution == "3840x2160")
+ #expect(decision.subtitleMode == .embedded)
+ #expect(decision.advancedSubtitleMode == .text)
+ #expect(decision.musicBitrate == nil)
+ }
+
+ @Test func constrainedVideoForcesTheExactDownloadTarget() throws {
+ let item = try decodeDownloadItem(Self.fourKVideoJSON)
+ let preferences = PlexDownloadPreferences(
+ videoQuality: .hd4Mbps,
+ musicQuality: .original,
+ subtitlePreference: .selectable
+ )
+
+ let decision = try preferences.decisionParameters(
+ for: item,
+ source: PlexPlaybackSource(mediaIndex: 0, partIndex: 0),
+ sessionIdentifier: "download-session",
+ clientProfileName: "generic",
+ clientProfileExtra: "static-profile"
+ )
+
+ #expect(decision.allowsDirectPlay == false)
+ #expect(decision.allowsDirectStream == false)
+ #expect(decision.videoQuality == 99)
+ #expect(decision.videoBitrate == 4_000)
+ #expect(decision.videoResolution == "1280x720")
+ #expect(decision.subtitleMode == .embedded)
+ #expect(decision.advancedSubtitleMode == .text)
+ #expect(decision.clientProfileName == "generic")
+ #expect(decision.clientProfileExtra == "static-profile")
+ }
+
+ @Test func subtitleChoicesMapOnlyToDocumentedQueueModes() throws {
+ let item = try decodeDownloadItem(Self.fourKVideoJSON)
+ let source = PlexPlaybackSource(mediaIndex: 0, partIndex: 0)
+ let expected: [
+ PlexDownloadSubtitlePreference: (
+ PlexDownloadSubtitleMode,
+ PlexDownloadAdvancedSubtitleMode?
+ )
+ ] = [
+ .selectable: (.embedded, .text),
+ .burn: (.burn, .burn),
+ .none: (.none, nil),
+ ]
+
+ for preference in PlexDownloadSubtitlePreference.allCases {
+ let decision = try PlexDownloadPreferences(
+ videoQuality: .original,
+ musicQuality: .original,
+ subtitlePreference: preference
+ ).decisionParameters(
+ for: item,
+ source: source,
+ sessionIdentifier: "download-session"
+ )
+ #expect(decision.subtitleMode == expected[preference]?.0)
+ #expect(decision.advancedSubtitleMode == expected[preference]?.1)
+ }
+ }
+
+ @Test func musicQualityUsesOnlyTheMusicDecisionContract() throws {
+ let item = try decodeDownloadItem(#"""
+ {
+ "ratingKey": "84",
+ "key": "/library/metadata/84",
+ "title": "Track",
+ "type": "track",
+ "Media": [{
+ "container": "flac",
+ "audioCodec": "flac",
+ "bitrate": 900,
+ "Part": [{"key": "/library/parts/9/file.flac"}]
+ }]
+ }
+ """#)
+ let preferences = PlexDownloadPreferences(
+ videoQuality: .sd1500Kbps,
+ musicQuality: .kbps192,
+ subtitlePreference: .burn
+ )
+
+ let decision = try preferences.decisionParameters(
+ for: item,
+ source: PlexPlaybackSource(mediaIndex: 0, partIndex: 0),
+ sessionIdentifier: "music-download"
+ )
+
+ #expect(decision.allowsDirectPlay == false)
+ #expect(decision.allowsDirectStreamAudio == false)
+ #expect(decision.musicBitrate == 192)
+ #expect(decision.videoBitrate == nil)
+ #expect(decision.videoQuality == nil)
+ #expect(decision.videoResolution == nil)
+ #expect(decision.subtitleMode == nil)
+ #expect(decision.advancedSubtitleMode == nil)
+ }
+
+ @Test func multipartVideoUsesPlexJoinIndexAndForcesOneConvertedFile() throws {
+ let item = try decodeDownloadItem(#"""
+ {
+ "ratingKey": "multipart",
+ "key": "/library/metadata/multipart",
+ "title": "Multipart Movie",
+ "type": "movie",
+ "Media": [{
+ "container": "mkv",
+ "videoCodec": "h264",
+ "audioCodec": "aac",
+ "width": 1920,
+ "height": 1080,
+ "bitrate": 8000,
+ "Part": [
+ {"key": "/library/parts/70/first.mkv"},
+ {"key": "/library/parts/71/second.mkv"}
+ ]
+ }]
+ }
+ """#)
+
+ let decision = try PlexDownloadPreferences.default.decisionParameters(
+ for: item,
+ source: PlexPlaybackSource(mediaIndex: 0, partIndex: -1),
+ sessionIdentifier: "multipart-download"
+ )
+
+ #expect(decision.mediaIndex == 0)
+ #expect(decision.partIndex == -1)
+ #expect(decision.allowsDirectPlay == false)
+ #expect(decision.allowsDirectStream == false)
+ #expect(decision.allowsDirectStreamAudio == false)
+ #expect(decision.deliveryProtocol == .http)
+ }
+
+ @Test func invalidOrFactlessSourcesFailClosed() throws {
+ let item = try decodeDownloadItem(#"""
+ {
+ "ratingKey": "42",
+ "title": "Unknown",
+ "type": "movie",
+ "Media": [{"Part": [{"key": "/library/parts/7/file.bin"}]}]
+ }
+ """#)
+
+ #expect(throws: PlexDownloadPreferencesError.unsupportedMedia) {
+ _ = try PlexDownloadPreferences.default.decisionParameters(
+ for: item,
+ source: PlexPlaybackSource(mediaIndex: 0, partIndex: 0),
+ sessionIdentifier: "download-session"
+ )
+ }
+ #expect(throws: PlexDownloadPreferencesError.invalidMediaSource) {
+ _ = try PlexDownloadPreferences.default.decisionParameters(
+ for: item,
+ source: PlexPlaybackSource(mediaIndex: 1, partIndex: 0),
+ sessionIdentifier: "download-session"
+ )
+ }
+ #expect(throws: PlexDownloadPreferencesError.invalidMediaSource) {
+ _ = try PlexDownloadPreferences.default.decisionParameters(
+ for: item,
+ source: PlexPlaybackSource(mediaIndex: 0, partIndex: -1),
+ sessionIdentifier: "download-session"
+ )
+ }
+ }
+
+ private static let fourKVideoJSON = #"""
+ {
+ "ratingKey": "42",
+ "key": "/library/metadata/42",
+ "title": "Movie",
+ "type": "movie",
+ "Media": [{
+ "videoCodec": "hevc",
+ "audioCodec": "aac",
+ "width": 3840,
+ "height": 2160,
+ "bitrate": 30000,
+ "Part": [{"key": "/library/parts/7/file.mkv"}]
+ }]
+ }
+ """#
+}
+
+private func decodeDownloadItem(_ json: String) throws -> PlexMediaItem {
+ try JSONDecoder().decode(PlexMediaItem.self, from: Data(json.utf8))
+}
diff --git a/PlexBarTests/PlexDownloadQueueTests.swift b/PlexBarTests/PlexDownloadQueueTests.swift
new file mode 100644
index 0000000..db8fba9
--- /dev/null
+++ b/PlexBarTests/PlexDownloadQueueTests.swift
@@ -0,0 +1,392 @@
+import Foundation
+import Testing
+@testable import PlexBar
+
+@Suite(.serialized)
+struct PlexDownloadQueueTests {
+ @Test func createsAndFetchesTheClientScopedQueue() async throws {
+ let capture = RequestCapture()
+ let session = makeDownloadQueueMockSession { request in
+ capture.record(request)
+ return try response(
+ for: request,
+ data: Data(#"{"MediaContainer":{"size":1,"DownloadQueue":[{"id":7,"status":"done","itemCount":0}]}}"#.utf8)
+ )
+ }
+ let client = PlexAPIClient(session: session)
+
+ let created = try await client.fetchOrCreateDownloadQueue(using: configuration)
+ let fetched = try await client.fetchDownloadQueue(queueID: 7, using: configuration)
+
+ #expect(created == PlexDownloadQueue(id: 7, status: .done, itemCount: 0))
+ #expect(fetched == created)
+ #expect(capture.requests.map(\.httpMethod) == ["POST", "GET"])
+ #expect(capture.requests.compactMap(\.url?.path) == [
+ "/downloadQueue",
+ "/downloadQueue/7",
+ ])
+ #expect(capture.requests.allSatisfy {
+ $0.value(forHTTPHeaderField: "X-Plex-Token") == "server-token"
+ })
+ }
+
+ @Test func addsMetadataWithOnlyTheExplicitDecisionContract() async throws {
+ let capture = RequestCapture()
+ let session = makeDownloadQueueMockSession { request in
+ capture.record(request)
+ return try response(
+ for: request,
+ data: Data(#"{"MediaContainer":{"size":2,"AddedQueueItems":[{"key":"/library/metadata/42","id":11},{"key":"/library/metadata/43","id":12}]}}"#.utf8)
+ )
+ }
+ let decision = PlexDownloadDecisionParameters(
+ mediaPath: "/library/metadata/42",
+ mediaIndex: 0,
+ partIndex: 0,
+ deliveryProtocol: .http,
+ allowsDirectPlay: true,
+ allowsDirectStream: true,
+ allowsDirectStreamAudio: true,
+ subtitleMode: .sidecar,
+ advancedSubtitleMode: .text,
+ videoBitrate: 8_000,
+ videoQuality: 99,
+ videoResolution: "1920x1080",
+ sessionIdentifier: "download-session",
+ clientProfileName: "generic",
+ clientProfileExtra: "profile-contract"
+ )
+
+ let added = try await PlexAPIClient(session: session).addToDownloadQueue(
+ keys: ["/library/metadata/42", "/library/metadata/43"],
+ queueID: 7,
+ decision: decision,
+ using: configuration
+ )
+
+ #expect(added == [
+ PlexAddedDownloadQueueItem(key: "/library/metadata/42", id: 11),
+ PlexAddedDownloadQueueItem(key: "/library/metadata/43", id: 12),
+ ])
+ let request = try #require(capture.request)
+ let components = try #require(request.url.flatMap {
+ URLComponents(url: $0, resolvingAgainstBaseURL: false)
+ })
+ #expect(request.httpMethod == "POST")
+ #expect(components.path == "/downloadQueue/7/add")
+ #expect(queryValue("keys", in: components) == "/library/metadata/42,/library/metadata/43")
+ #expect(queryValue("path", in: components) == "/library/metadata/42")
+ #expect(queryValue("mediaIndex", in: components) == "0")
+ #expect(queryValue("partIndex", in: components) == "0")
+ #expect(queryValue("protocol", in: components) == "http")
+ #expect(queryValue("directPlay", in: components) == "1")
+ #expect(queryValue("directStream", in: components) == "1")
+ #expect(queryValue("directStreamAudio", in: components) == "1")
+ #expect(queryValue("subtitles", in: components) == "sidecar")
+ #expect(queryValue("advancedSubtitles", in: components) == "text")
+ #expect(queryValue("videoBitrate", in: components) == "8000")
+ #expect(queryValue("videoQuality", in: components) == "99")
+ #expect(queryValue("videoResolution", in: components) == "1920x1080")
+ #expect(queryValue("musicBitrate", in: components) == nil)
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Session-Identifier") == "download-session")
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Client-Profile-Name") == "generic")
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Client-Profile-Extra") == "profile-contract")
+ }
+
+ @Test func sendsTheDocumentedMultipartJoinIndex() async throws {
+ let capture = RequestCapture()
+ let session = makeDownloadQueueMockSession { request in
+ capture.record(request)
+ return try response(
+ for: request,
+ data: Data(#"{"MediaContainer":{"size":1,"AddedQueueItems":[{"key":"/library/metadata/42","id":11}]}}"#.utf8)
+ )
+ }
+ let decision = PlexDownloadDecisionParameters(
+ mediaPath: "/library/metadata/42",
+ mediaIndex: 0,
+ partIndex: -1,
+ deliveryProtocol: .http,
+ allowsDirectPlay: false,
+ allowsDirectStream: false,
+ allowsDirectStreamAudio: false,
+ videoQuality: 99,
+ sessionIdentifier: "multipart-download"
+ )
+
+ _ = try await PlexAPIClient(session: session).addToDownloadQueue(
+ keys: ["/library/metadata/42"],
+ queueID: 7,
+ decision: decision,
+ using: configuration
+ )
+
+ let request = try #require(capture.request)
+ let components = try #require(request.url.flatMap {
+ URLComponents(url: $0, resolvingAgainstBaseURL: false)
+ })
+ #expect(queryValue("mediaIndex", in: components) == "0")
+ #expect(queryValue("partIndex", in: components) == "-1")
+ #expect(queryValue("directPlay", in: components) == "0")
+ #expect(queryValue("directStream", in: components) == "0")
+ #expect(queryValue("directStreamAudio", in: components) == "0")
+ }
+
+ @Test func decodesAuthoritativeProcessingStateAndDecisionFacts() async throws {
+ let capture = RequestCapture()
+ let session = makeDownloadQueueMockSession { request in
+ capture.record(request)
+ return try response(for: request, data: Self.processingItemsData)
+ }
+
+ let items = try await PlexAPIClient(session: session).fetchDownloadQueueItems(
+ queueID: 7,
+ itemIDs: [11, 12],
+ using: configuration
+ )
+
+ let item = try #require(items.first)
+ #expect(items.count == 1)
+ #expect(item.id == 11)
+ #expect(item.queueID == 7)
+ #expect(item.key == "/library/metadata/42")
+ #expect(item.status == .processing)
+ #expect(item.decisionResult?.generalDecisionCode == 1001)
+ #expect(item.decisionResult?.directPlayDecisionCode == 3001)
+ #expect(item.transcodeSession?.progress == 47.5)
+ #expect(item.transcodeSession?.size == 1_048_576)
+ #expect(item.transcodeSession?.protocol == "http")
+ #expect(capture.request?.url?.path == "/downloadQueue/7/items/11,12")
+ }
+
+ @Test func deletesAndRestartsExactQueueItems() async throws {
+ let capture = RequestCapture()
+ let session = makeDownloadQueueMockSession { request in
+ capture.record(request)
+ return try response(for: request, data: Data())
+ }
+ let client = PlexAPIClient(session: session)
+
+ try await client.deleteDownloadQueueItems(
+ queueID: 7,
+ itemIDs: [11, 12],
+ using: configuration
+ )
+ try await client.restartDownloadQueueItems(
+ queueID: 7,
+ itemIDs: [11, 12],
+ using: configuration
+ )
+
+ #expect(capture.requests.map(\.httpMethod) == ["DELETE", "POST"])
+ #expect(capture.requests.compactMap(\.url?.path) == [
+ "/downloadQueue/7/items/11,12",
+ "/downloadQueue/7/items/11,12/restart",
+ ])
+ }
+
+ @Test func decodesTheQueueItemDecisionAndBuildsAStreamingMediaRequest() async throws {
+ let capture = RequestCapture()
+ let session = makeDownloadQueueMockSession { request in
+ capture.record(request)
+ return try response(for: request, data: Self.decisionData)
+ }
+ let client = PlexAPIClient(session: session)
+
+ let document = try await client.fetchDownloadQueueDecisionDocument(
+ queueID: 7,
+ itemID: 11,
+ using: configuration
+ )
+ let decision = document.decision
+ let mediaRequest = try client.downloadQueueMediaRequest(
+ queueID: 7,
+ itemID: 11,
+ using: configuration
+ )
+
+ #expect(decision.allowSync == true)
+ #expect(decision.generalDecisionCode == 1000)
+ #expect(decision.directPlayDecisionCode == 1000)
+ #expect(decision.transcodeDecisionCode == nil)
+ #expect(decision.resourceSession == "resource-session")
+ #expect(decision.metadata.map(\.ratingKey) == ["42"])
+ #expect(document.data == Self.decisionData)
+ #expect(capture.request?.url?.path == "/downloadQueue/7/item/11/decision")
+ #expect(mediaRequest.httpMethod == "GET")
+ #expect(mediaRequest.url?.path == "/downloadQueue/7/item/11/media")
+ #expect(mediaRequest.value(forHTTPHeaderField: "Accept") == nil)
+ #expect(mediaRequest.value(forHTTPHeaderField: "X-Plex-Token") == "server-token")
+ }
+
+ @Test func rejectsInvalidQueueIdentifiersAndExternalMetadataKeys() async throws {
+ let client = PlexAPIClient()
+ let decision = PlexDownloadDecisionParameters()
+
+ await #expect(throws: PlexAPIError.self) {
+ _ = try await client.fetchDownloadQueue(queueID: 0, using: configuration)
+ }
+ await #expect(throws: PlexAPIError.self) {
+ _ = try await client.addToDownloadQueue(
+ keys: ["https://outside.example/library/metadata/42"],
+ queueID: 7,
+ decision: decision,
+ using: configuration
+ )
+ }
+ await #expect(throws: PlexAPIError.self) {
+ _ = try await client.fetchDownloadQueueItems(
+ queueID: 7,
+ itemIDs: [],
+ using: configuration
+ )
+ }
+ #expect(throws: PlexAPIError.self) {
+ _ = try client.downloadQueueMediaRequest(
+ queueID: 7,
+ itemID: -1,
+ using: configuration
+ )
+ }
+
+ await #expect(throws: PlexAPIError.self) {
+ _ = try await client.addToDownloadQueue(
+ keys: ["/library/metadata/42"],
+ queueID: 7,
+ decision: PlexDownloadDecisionParameters(videoQuality: 100),
+ using: configuration
+ )
+ }
+ await #expect(throws: PlexAPIError.self) {
+ _ = try await client.addToDownloadQueue(
+ keys: ["/library/metadata/42"],
+ queueID: 7,
+ decision: PlexDownloadDecisionParameters(videoResolution: "1080p"),
+ using: configuration
+ )
+ }
+ }
+
+ private var configuration: PlexConnectionConfiguration {
+ PlexConnectionConfiguration(
+ serverURL: URL(string: "https://plex.test:32400")!,
+ token: "server-token",
+ clientContext: PlexClientContext(clientIdentifier: "client-123"),
+ serverIdentifier: "server-id"
+ )
+ }
+
+ private static let processingItemsData = Data(#"""
+ {
+ "MediaContainer": {
+ "size": 1,
+ "DownloadQueueItem": [
+ {
+ "id": 11,
+ "queueId": 7,
+ "key": "/library/metadata/42",
+ "status": "processing",
+ "DecisionResult": {
+ "generalDecisionCode": 1001,
+ "generalDecisionText": "Direct play not available; Conversion OK.",
+ "directPlayDecisionCode": 3001,
+ "directPlayDecisionText": "Not enough bandwidth for direct play of this item.",
+ "transcodeDecisionCode": 1001,
+ "transcodeDecisionText": "Direct play not available; Conversion OK."
+ },
+ "TranscodeSession": {
+ "key": "/transcode/sessions/download",
+ "throttled": false,
+ "complete": false,
+ "progress": 47.5,
+ "size": 1048576,
+ "speed": 8.25,
+ "error": false,
+ "duration": 300000000,
+ "context": "streaming",
+ "sourceVideoCodec": "h264",
+ "sourceAudioCodec": "aac",
+ "protocol": "http",
+ "transcodeHwRequested": true,
+ "transcodeHwFullPipeline": false
+ }
+ }
+ ]
+ }
+ }
+ """#.utf8)
+
+ private static let decisionData = Data(#"""
+ {
+ "MediaContainer": {
+ "allowSync": "1",
+ "generalDecisionCode": "1000",
+ "generalDecisionText": "Direct play OK.",
+ "directPlayDecisionCode": 1000,
+ "directPlayDecisionText": "Direct play OK.",
+ "resourceSession": "resource-session",
+ "Metadata": [
+ {
+ "ratingKey": "42",
+ "key": "/library/metadata/42",
+ "title": "Episode",
+ "type": "episode",
+ "Media": []
+ }
+ ]
+ }
+ }
+ """#.utf8)
+}
+
+private func makeDownloadQueueMockSession(
+ handler: @escaping @Sendable (URLRequest) throws -> (HTTPURLResponse, Data)
+) -> URLSession {
+ DownloadQueueMockURLProtocol.requestHandler = handler
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.protocolClasses = [DownloadQueueMockURLProtocol.self]
+ return URLSession(configuration: configuration)
+}
+
+private func response(
+ for request: URLRequest,
+ statusCode: Int = 200,
+ data: Data
+) throws -> (HTTPURLResponse, Data) {
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: statusCode,
+ httpVersion: nil,
+ headerFields: ["Content-Type": "application/json"]
+ ))
+ return (response, data)
+}
+
+private func queryValue(_ name: String, in components: URLComponents) -> String? {
+ components.queryItems?.first(where: { $0.name == name })?.value
+}
+
+private final class DownloadQueueMockURLProtocol: URLProtocol, @unchecked Sendable {
+ nonisolated(unsafe) static var requestHandler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))?
+
+ override static func canInit(with request: URLRequest) -> Bool { true }
+
+ override static func canonicalRequest(for request: URLRequest) -> URLRequest { request }
+
+ override func startLoading() {
+ guard let handler = Self.requestHandler else {
+ client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
+ return
+ }
+ do {
+ let (response, data) = try handler(request)
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: data)
+ client?.urlProtocolDidFinishLoading(self)
+ } catch {
+ client?.urlProtocol(self, didFailWithError: error)
+ }
+ }
+
+ override func stopLoading() {}
+}
diff --git a/PlexBarTests/PlexDownloadTransferCoordinatorTests.swift b/PlexBarTests/PlexDownloadTransferCoordinatorTests.swift
new file mode 100644
index 0000000..f9a5b3e
--- /dev/null
+++ b/PlexBarTests/PlexDownloadTransferCoordinatorTests.swift
@@ -0,0 +1,577 @@
+import Foundation
+import Testing
+@testable import PlexBar
+
+@Suite(.serialized)
+struct PlexDownloadTransferCoordinatorTests {
+ @Test func nativeBackgroundConfigurationUsesOneRecoverableNonCachingSession() {
+ let identifier = PlexDownloadTransferCoordinator.backgroundSessionIdentifier
+ let configuration = PlexDownloadTransferSession.backgroundConfiguration(
+ identifier: identifier
+ )
+
+ #expect(identifier == "com.crapshack.PlexBar.downloads")
+ #expect(configuration.identifier == identifier)
+ #expect(configuration.sessionSendsLaunchEvents)
+ #expect(!configuration.isDiscretionary)
+ #expect(configuration.requestCachePolicy == .reloadIgnoringLocalAndRemoteCacheData)
+ #expect(configuration.urlCache == nil)
+ #expect(configuration.httpCookieStorage == nil)
+ }
+
+ @Test func schedulePersistsIdentityBeforeResumingWithoutPersistingSecrets() async throws {
+ let fixture = try TransferFixture()
+ defer { fixture.remove() }
+
+ let record = try await fixture.coordinator.schedule(
+ fixture.transferRequest(),
+ transferID: fixture.transferID,
+ createdAt: fixture.createdAt
+ )
+
+ #expect(record.id == fixture.transferID)
+ #expect(record.state == .transferring)
+ #expect(fixture.sessionHarness.resumedTaskIDs == [record.taskIdentifier])
+ let records = try await fixture.registry.records()
+ #expect(records.count == 1)
+ #expect(records.first?.id == fixture.transferID)
+ #expect(records.first?.packageIdentity == fixture.packageIdentity)
+
+ let registryData = try Data(contentsOf: fixture.registryURL)
+ let registryText = try #require(String(data: registryData, encoding: .utf8))
+ #expect(!registryText.contains("server-token-secret"))
+ #expect(!registryText.contains("plex.test"))
+ }
+
+ @Test func invalidAndDuplicateRequestsNeverCreateASecondTask() async throws {
+ let fixture = try TransferFixture()
+ defer { fixture.remove() }
+ var invalidRequest = fixture.mediaRequest
+ invalidRequest.setValue(nil, forHTTPHeaderField: "X-Plex-Token")
+
+ await #expect(throws: PlexDownloadTransferError.self) {
+ _ = try await fixture.coordinator.schedule(PlexDownloadTransferRequest(
+ packageIdentity: fixture.packageIdentity,
+ title: "Episode",
+ mediaType: "episode",
+ decisionData: fixture.decisionData,
+ mediaFileExtension: "mp4",
+ contentType: "video/mp4",
+ request: invalidRequest
+ ))
+ }
+ _ = try await fixture.coordinator.schedule(
+ fixture.transferRequest(),
+ transferID: fixture.transferID
+ )
+ await #expect(throws: PlexDownloadTransferError.self) {
+ _ = try await fixture.coordinator.schedule(fixture.transferRequest())
+ }
+
+ #expect(fixture.sessionHarness.createdTaskIDs.count == 1)
+ }
+
+ @Test func expiredAuthorizationNeverResumesAndRemovesTheSuspendedRecord() async throws {
+ let fixture = try TransferFixture()
+ defer { fixture.remove() }
+ let authorization = TransferAuthorizationSequence([true, false])
+
+ await #expect(throws: PlexDownloadTransferError.authorizationExpired) {
+ _ = try await fixture.coordinator.schedule(
+ fixture.transferRequest(),
+ transferID: fixture.transferID,
+ authorizationCheck: {
+ authorization.next()
+ }
+ )
+ }
+
+ #expect(fixture.sessionHarness.createdTaskIDs == [100])
+ #expect(fixture.sessionHarness.resumedTaskIDs.isEmpty)
+ #expect(fixture.sessionHarness.cancelledTaskIDs == [100])
+ #expect(try await fixture.registry.records().isEmpty)
+ }
+
+ @Test func completionPublishesTheAtomicPackageAndClearsTransferState() async throws {
+ let fixture = try TransferFixture()
+ defer { fixture.remove() }
+ let record = try await fixture.coordinator.schedule(
+ fixture.transferRequest(),
+ transferID: fixture.transferID
+ )
+ let mediaData = Data("background-media".utf8)
+ let temporaryURL = try fixture.makeFile(data: mediaData)
+ let handoff = try fixture.handoffStore.accept(
+ temporaryFileURL: temporaryURL,
+ transferID: fixture.transferID,
+ response: fixture.response()
+ ).get()
+
+ fixture.sessionHarness.emit(.progress(
+ taskIdentifier: record.taskIdentifier,
+ taskDescription: fixture.transferID.uuidString,
+ bytesReceived: 8,
+ bytesExpected: Int64(mediaData.count)
+ ))
+ fixture.sessionHarness.emit(.handoffCompleted(
+ taskIdentifier: record.taskIdentifier,
+ taskDescription: fixture.transferID.uuidString,
+ result: .success(handoff)
+ ))
+ fixture.sessionHarness.emit(.taskCompleted(
+ taskIdentifier: record.taskIdentifier,
+ taskDescription: fixture.transferID.uuidString,
+ errorCode: nil
+ ))
+
+ #expect(try await eventually {
+ try await fixture.registry.records().isEmpty
+ })
+ let package = try #require(try await fixture.packageStore.package(
+ withID: fixture.packageIdentity.packageID
+ ))
+ #expect(package.manifest.identity == fixture.packageIdentity)
+ #expect(package.manifest.mediaFileName == "media.mp4")
+ #expect(package.manifest.contentType == "video/mp4")
+ #expect(try Data(contentsOf: package.mediaURL) == mediaData)
+ #expect(try fixture.handoffStore.handoff(for: fixture.transferID).get() == nil)
+ #expect(await fixture.coordinator.progress(for: fixture.transferID) == nil)
+ }
+
+ @Test func relaunchRecoveryResumesTheExactSuspendedTaskAndCancelsOrphans() async throws {
+ let fixture = try TransferFixture()
+ defer { fixture.remove() }
+ let record = fixture.record(taskIdentifier: 77, state: .scheduled)
+ try await fixture.registry.save(record)
+ fixture.sessionHarness.addTask(
+ identifier: 77,
+ description: fixture.transferID.uuidString,
+ state: .suspended,
+ received: 40,
+ expected: 100
+ )
+ fixture.sessionHarness.addTask(
+ identifier: 78,
+ description: UUID().uuidString,
+ state: .running
+ )
+ let relaunchedRegistry = PlexDownloadTransferRegistry(rootURL: fixture.rootURL)
+ let relaunchedCoordinator = PlexDownloadTransferCoordinator(
+ registry: relaunchedRegistry,
+ packageStore: PlexDownloadPackageStore(rootURL: fixture.rootURL),
+ handoffStore: fixture.handoffStore,
+ session: fixture.sessionHarness.session
+ )
+
+ try await relaunchedCoordinator.start()
+
+ #expect(fixture.sessionHarness.resumedTaskIDs == [77])
+ #expect(fixture.sessionHarness.cancelledTaskIDs == [78])
+ let recovered = try #require(try await relaunchedRegistry.record(
+ withID: fixture.transferID
+ ))
+ #expect(recovered.state == .transferring)
+ #expect(await relaunchedCoordinator.progress(for: fixture.transferID) == PlexDownloadTransferProgress(
+ transferID: fixture.transferID,
+ bytesReceived: 40,
+ bytesExpected: 100
+ ))
+ }
+
+ @Test func explicitPauseSurvivesRelaunchWithoutResumingTheTask() async throws {
+ let fixture = try TransferFixture()
+ defer { fixture.remove() }
+ try await fixture.registry.save(fixture.record(taskIdentifier: 77, state: .paused))
+ fixture.sessionHarness.addTask(
+ identifier: 77,
+ description: fixture.transferID.uuidString,
+ state: .suspended,
+ received: 40,
+ expected: 100
+ )
+ let relaunchedRegistry = PlexDownloadTransferRegistry(rootURL: fixture.rootURL)
+ let relaunchedCoordinator = PlexDownloadTransferCoordinator(
+ registry: relaunchedRegistry,
+ packageStore: PlexDownloadPackageStore(rootURL: fixture.rootURL),
+ handoffStore: fixture.handoffStore,
+ session: fixture.sessionHarness.session
+ )
+
+ try await relaunchedCoordinator.start()
+
+ #expect(fixture.sessionHarness.resumedTaskIDs.isEmpty)
+ #expect(fixture.sessionHarness.suspendedTaskIDs.isEmpty)
+ let recovered = try #require(try await relaunchedRegistry.record(
+ withID: fixture.transferID
+ ))
+ #expect(recovered.state == .paused)
+ #expect(await relaunchedCoordinator.progress(for: fixture.transferID) == PlexDownloadTransferProgress(
+ transferID: fixture.transferID,
+ bytesReceived: 40,
+ bytesExpected: 100
+ ))
+ }
+
+ @Test func pauseAndResumeControlOnlyThePersistedTransfer() async throws {
+ let fixture = try TransferFixture()
+ defer { fixture.remove() }
+ let record = try await fixture.coordinator.schedule(
+ fixture.transferRequest(),
+ transferID: fixture.transferID
+ )
+
+ try await fixture.coordinator.pause(transferID: fixture.transferID)
+ let paused = try #require(try await fixture.registry.record(withID: fixture.transferID))
+ #expect(paused.state == .paused)
+ #expect(fixture.sessionHarness.suspendedTaskIDs == [record.taskIdentifier])
+
+ try await fixture.coordinator.resume(transferID: fixture.transferID)
+ let resumed = try #require(try await fixture.registry.record(withID: fixture.transferID))
+ #expect(resumed.state == .transferring)
+ #expect(fixture.sessionHarness.resumedTaskIDs == [record.taskIdentifier, record.taskIdentifier])
+ }
+
+ @Test func relaunchPublishesAnAcceptedHandoffEvenWhenTheTaskIsGone() async throws {
+ let fixture = try TransferFixture()
+ defer { fixture.remove() }
+ try await fixture.registry.save(fixture.record(taskIdentifier: 91, state: .downloaded))
+ let mediaData = Data("handoff-survived-process-exit".utf8)
+ _ = try fixture.handoffStore.accept(
+ temporaryFileURL: fixture.makeFile(data: mediaData),
+ transferID: fixture.transferID,
+ response: fixture.response()
+ ).get()
+ let relaunchedRegistry = PlexDownloadTransferRegistry(rootURL: fixture.rootURL)
+ let relaunchedPackageStore = PlexDownloadPackageStore(rootURL: fixture.rootURL)
+ let relaunchedCoordinator = PlexDownloadTransferCoordinator(
+ registry: relaunchedRegistry,
+ packageStore: relaunchedPackageStore,
+ handoffStore: fixture.handoffStore,
+ session: fixture.sessionHarness.session
+ )
+
+ try await relaunchedCoordinator.start()
+
+ #expect(try await relaunchedRegistry.records().isEmpty)
+ let package = try #require(try await relaunchedPackageStore.package(
+ withID: fixture.packageIdentity.packageID
+ ))
+ #expect(try Data(contentsOf: package.mediaURL) == mediaData)
+ }
+
+ @Test func missingTasksAndServerErrorsRemainExplicitFailures() async throws {
+ let fixture = try TransferFixture()
+ defer { fixture.remove() }
+ try await fixture.registry.save(fixture.record(taskIdentifier: 55))
+ try await fixture.coordinator.start()
+ let missingTaskRecord = try #require(try await fixture.registry.record(
+ withID: fixture.transferID
+ ))
+ #expect(missingTaskRecord.state == .failed)
+ #expect(missingTaskRecord.failure == .missingTask)
+
+ try await fixture.coordinator.cancel(transferID: fixture.transferID)
+ let scheduled = try await fixture.coordinator.schedule(
+ fixture.transferRequest(),
+ transferID: fixture.transferID
+ )
+ fixture.sessionHarness.emit(.handoffCompleted(
+ taskIdentifier: scheduled.taskIdentifier,
+ taskDescription: fixture.transferID.uuidString,
+ result: .failure(.serverStatus(401))
+ ))
+ fixture.sessionHarness.emit(.taskCompleted(
+ taskIdentifier: scheduled.taskIdentifier,
+ taskDescription: fixture.transferID.uuidString,
+ errorCode: nil
+ ))
+
+ #expect(try await eventually {
+ try await fixture.registry.record(withID: fixture.transferID)?.failure
+ == .serverResponse
+ })
+ let serverFailure = try #require(try await fixture.registry.record(
+ withID: fixture.transferID
+ ))
+ #expect(serverFailure.state == .failed)
+ #expect(serverFailure.failure == .serverResponse)
+ }
+
+ @Test func cancellationIsExactAndIdempotent() async throws {
+ let fixture = try TransferFixture()
+ defer { fixture.remove() }
+ let record = try await fixture.coordinator.schedule(
+ fixture.transferRequest(),
+ transferID: fixture.transferID
+ )
+
+ try await fixture.coordinator.cancel(transferID: fixture.transferID)
+ try await fixture.coordinator.cancel(transferID: fixture.transferID)
+
+ #expect(fixture.sessionHarness.cancelledTaskIDs == [record.taskIdentifier])
+ #expect(try await fixture.registry.records().isEmpty)
+ }
+}
+
+private final class TransferAuthorizationSequence: @unchecked Sendable {
+ private let lock = NSLock()
+ private var values: [Bool]
+
+ init(_ values: [Bool]) {
+ self.values = values
+ }
+
+ func next() -> Bool {
+ lock.withLock {
+ values.isEmpty ? false : values.removeFirst()
+ }
+ }
+}
+
+private struct TransferFixture {
+ let rootURL: URL
+ let transferID = UUID(uuidString: "7B2138C7-AFE6-44CB-A0EC-176F1C4869EE")!
+ let packageIdentity = PlexDownloadPackageIdentity(
+ packageID: UUID(uuidString: "BB6DF0A6-1FAF-43D1-AAB4-F5529C7DF07C")!,
+ accountID: 9,
+ serverIdentifier: "server-id",
+ queueID: 7,
+ queueItemID: 11,
+ metadataKey: "/library/metadata/42",
+ ratingKey: "42"
+ )
+ let createdAt = Date(timeIntervalSince1970: 1_788_134_400)
+ let registry: PlexDownloadTransferRegistry
+ let packageStore: PlexDownloadPackageStore
+ let handoffStore: PlexDownloadHandoffStore
+ let sessionHarness: TransferSessionHarness
+ let coordinator: PlexDownloadTransferCoordinator
+
+ init() throws {
+ rootURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent("PlexDownloadTransferCoordinatorTests-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true)
+ registry = PlexDownloadTransferRegistry(rootURL: rootURL)
+ packageStore = PlexDownloadPackageStore(rootURL: rootURL)
+ handoffStore = PlexDownloadHandoffStore(rootURL: rootURL)
+ sessionHarness = TransferSessionHarness()
+ coordinator = PlexDownloadTransferCoordinator(
+ registry: registry,
+ packageStore: packageStore,
+ handoffStore: handoffStore,
+ session: sessionHarness.session
+ )
+ }
+
+ var registryURL: URL {
+ rootURL
+ .appendingPathComponent("Transfers", isDirectory: true)
+ .appendingPathComponent("registry.json")
+ }
+
+ var mediaRequest: URLRequest {
+ var request = URLRequest(
+ url: URL(string: "https://plex.test/downloadQueue/7/item/11/media")!
+ )
+ request.httpMethod = "GET"
+ request.setValue("server-token-secret", forHTTPHeaderField: "X-Plex-Token")
+ return request
+ }
+
+ var decisionData: Data {
+ Data(#"{"MediaContainer":{"allowSync":"1","Metadata":[{"ratingKey":"42","key":"/library/metadata/42","title":"Episode","type":"episode","Media":[]}]}}"#.utf8)
+ }
+
+ func transferRequest() -> PlexDownloadTransferRequest {
+ PlexDownloadTransferRequest(
+ packageIdentity: packageIdentity,
+ title: "Episode",
+ mediaType: "episode",
+ decisionData: decisionData,
+ mediaFileExtension: "mkv",
+ contentType: "video/x-matroska",
+ request: mediaRequest
+ )
+ }
+
+ func record(
+ taskIdentifier: Int,
+ state: PlexDownloadTransferState = .scheduled
+ ) -> PlexDownloadTransferRecord {
+ PlexDownloadTransferRecord(
+ id: transferID,
+ packageIdentity: packageIdentity,
+ title: "Episode",
+ mediaType: "episode",
+ decisionData: decisionData,
+ mediaFileExtension: "mkv",
+ contentType: "video/x-matroska",
+ taskIdentifier: taskIdentifier,
+ createdAt: createdAt,
+ state: state
+ )
+ }
+
+ func makeFile(data: Data) throws -> URL {
+ let url = rootURL.appendingPathComponent("download-\(UUID().uuidString)")
+ try data.write(to: url)
+ return url
+ }
+
+ func response(statusCode: Int = 200) -> HTTPURLResponse {
+ HTTPURLResponse(
+ url: mediaRequest.url!,
+ statusCode: statusCode,
+ httpVersion: "HTTP/2",
+ headerFields: [
+ "Content-Type": "video/mp4",
+ "Content-Disposition": "attachment; filename=episode.mp4",
+ ]
+ )!
+ }
+
+ func remove() {
+ try? FileManager.default.removeItem(at: rootURL)
+ }
+}
+
+private final class TransferSessionHarness: @unchecked Sendable {
+ private let lock = NSLock()
+ private let eventStream = AsyncStream.makeStream(of: PlexDownloadTransferEvent.self)
+ private var nextTaskIdentifier = 100
+ private var taskSnapshots: [Int: PlexDownloadTransferTaskSnapshot] = [:]
+ private var _createdTaskIDs: [Int] = []
+ private var _resumedTaskIDs: [Int] = []
+ private var _suspendedTaskIDs: [Int] = []
+ private var _cancelledTaskIDs: [Int] = []
+
+ var session: PlexDownloadTransferSession {
+ PlexDownloadTransferSession(
+ events: eventStream.stream,
+ createTask: { [weak self] request, description in
+ self?.createTask(request: request, description: description)
+ },
+ tasks: { [weak self] in
+ self?.snapshots ?? []
+ },
+ resumeTask: { [weak self] identifier in
+ self?.resume(identifier: identifier)
+ },
+ cancelTask: { [weak self] identifier in
+ self?.cancel(identifier: identifier)
+ },
+ suspendTask: { [weak self] identifier in
+ self?.suspend(identifier: identifier)
+ }
+ )
+ }
+
+ var createdTaskIDs: [Int] { withLock { _createdTaskIDs } }
+ var resumedTaskIDs: [Int] { withLock { _resumedTaskIDs } }
+ var suspendedTaskIDs: [Int] { withLock { _suspendedTaskIDs } }
+ var cancelledTaskIDs: [Int] { withLock { _cancelledTaskIDs } }
+ private var snapshots: [PlexDownloadTransferTaskSnapshot] {
+ withLock { Array(taskSnapshots.values) }
+ }
+
+ func addTask(
+ identifier: Int,
+ description: String?,
+ state: PlexDownloadTransferTaskSnapshot.State,
+ received: Int64 = 0,
+ expected: Int64 = -1
+ ) {
+ withLock {
+ taskSnapshots[identifier] = PlexDownloadTransferTaskSnapshot(
+ taskIdentifier: identifier,
+ taskDescription: description,
+ state: state,
+ countOfBytesReceived: received,
+ countOfBytesExpectedToReceive: expected
+ )
+ }
+ }
+
+ func emit(_ event: PlexDownloadTransferEvent) {
+ eventStream.continuation.yield(event)
+ }
+
+ private func createTask(request: URLRequest, description: String) -> Int {
+ withLock {
+ let identifier = nextTaskIdentifier
+ nextTaskIdentifier += 1
+ _createdTaskIDs.append(identifier)
+ taskSnapshots[identifier] = PlexDownloadTransferTaskSnapshot(
+ taskIdentifier: identifier,
+ taskDescription: description,
+ state: .suspended,
+ countOfBytesReceived: 0,
+ countOfBytesExpectedToReceive: -1
+ )
+ return identifier
+ }
+ }
+
+ private func resume(identifier: Int) {
+ withLock {
+ guard let task = taskSnapshots[identifier] else { return }
+ _resumedTaskIDs.append(identifier)
+ taskSnapshots[identifier] = PlexDownloadTransferTaskSnapshot(
+ taskIdentifier: identifier,
+ taskDescription: task.taskDescription,
+ state: .running,
+ countOfBytesReceived: task.countOfBytesReceived,
+ countOfBytesExpectedToReceive: task.countOfBytesExpectedToReceive
+ )
+ }
+ }
+
+ private func suspend(identifier: Int) {
+ withLock {
+ guard let task = taskSnapshots[identifier] else { return }
+ _suspendedTaskIDs.append(identifier)
+ taskSnapshots[identifier] = PlexDownloadTransferTaskSnapshot(
+ taskIdentifier: identifier,
+ taskDescription: task.taskDescription,
+ state: .suspended,
+ countOfBytesReceived: task.countOfBytesReceived,
+ countOfBytesExpectedToReceive: task.countOfBytesExpectedToReceive
+ )
+ }
+ }
+
+ private func cancel(identifier: Int) {
+ withLock {
+ guard let task = taskSnapshots[identifier] else { return }
+ _cancelledTaskIDs.append(identifier)
+ taskSnapshots[identifier] = PlexDownloadTransferTaskSnapshot(
+ taskIdentifier: identifier,
+ taskDescription: task.taskDescription,
+ state: .canceling,
+ countOfBytesReceived: task.countOfBytesReceived,
+ countOfBytesExpectedToReceive: task.countOfBytesExpectedToReceive
+ )
+ }
+ }
+
+ private func withLock(_ body: () -> T) -> T {
+ lock.lock()
+ defer { lock.unlock() }
+ return body()
+ }
+}
+
+private func eventually(
+ timeout: Duration = .seconds(2),
+ condition: @escaping @Sendable () async throws -> Bool
+) async throws -> Bool {
+ let clock = ContinuousClock()
+ let deadline = clock.now.advanced(by: timeout)
+ while clock.now < deadline {
+ if try await condition() {
+ return true
+ }
+ try await Task.sleep(for: .milliseconds(10))
+ }
+ return try await condition()
+}
diff --git a/PlexBarTests/PlexEpisodeContinuityTests.swift b/PlexBarTests/PlexEpisodeContinuityTests.swift
new file mode 100644
index 0000000..b77b3ce
--- /dev/null
+++ b/PlexBarTests/PlexEpisodeContinuityTests.swift
@@ -0,0 +1,82 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+struct PlexEpisodeContinuityTests {
+ @Test
+ func nextEpisodeUsesLibraryOrderRatherThanResponseOrder() throws {
+ let current = try item(ratingKey: "episode-2", type: "episode", index: 2)
+ let values = try [
+ item(ratingKey: "episode-4", type: "episode", index: 4),
+ item(ratingKey: "featurette", type: "clip", index: 3),
+ item(ratingKey: "episode-3", type: "episode", index: 3),
+ current,
+ ]
+
+ let next = PlexEpisodeContinuity.nextEpisode(after: current, in: values)
+
+ #expect(next?.ratingKey == "episode-3")
+ }
+
+ @Test
+ func nextEpisodeFallsForwardWhenCurrentEpisodeIsMissing() throws {
+ let current = try item(ratingKey: "missing", type: "episode", index: 2)
+ let values = try [
+ item(ratingKey: "episode-1", type: "episode", index: 1),
+ item(ratingKey: "episode-3", type: "episode", index: 3),
+ ]
+
+ let next = PlexEpisodeContinuity.nextEpisode(after: current, in: values)
+
+ #expect(next?.ratingKey == "episode-3")
+ }
+
+ @Test
+ func nextSeasonCrossesToTheFirstLaterSeason() throws {
+ let seasons = try [
+ item(ratingKey: "season-3", type: "season", index: 3),
+ item(ratingKey: "specials", type: "season", index: 0),
+ item(ratingKey: "season-2", type: "season", index: 2),
+ ]
+
+ let next = PlexEpisodeContinuity.nextSeason(
+ afterRatingKey: "season-1",
+ index: 1,
+ in: seasons
+ )
+
+ #expect(next?.ratingKey == "season-2")
+ }
+
+ @Test
+ func nextSeasonReturnsNilAtEndOfShow() throws {
+ let seasons = try [
+ item(ratingKey: "season-1", type: "season", index: 1),
+ item(ratingKey: "season-2", type: "season", index: 2),
+ ]
+
+ let next = PlexEpisodeContinuity.nextSeason(
+ afterRatingKey: "season-2",
+ index: 2,
+ in: seasons
+ )
+
+ #expect(next == nil)
+ }
+
+ private func item(ratingKey: String, type: String, index: Int) throws -> PlexMediaItem {
+ let value: [String: Any] = [
+ "ratingKey": ratingKey,
+ "key": "/library/metadata/\(ratingKey)",
+ "type": type,
+ "title": ratingKey,
+ "index": index,
+ "Media": [],
+ ]
+ return try JSONDecoder().decode(
+ PlexMediaItem.self,
+ from: JSONSerialization.data(withJSONObject: value)
+ )
+ }
+}
diff --git a/PlexBarTests/PlexEpisodeSpoilerPresentationTests.swift b/PlexBarTests/PlexEpisodeSpoilerPresentationTests.swift
new file mode 100644
index 0000000..93a0488
--- /dev/null
+++ b/PlexBarTests/PlexEpisodeSpoilerPresentationTests.swift
@@ -0,0 +1,97 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+struct PlexEpisodeSpoilerPresentationTests {
+ @Test func policyUsesOnlyAuthoritativeEpisodeWatchState() throws {
+ let unwatchedEpisode = try item(
+ #"{"ratingKey":"1","type":"episode","title":"Unwatched","viewCount":0,"viewOffset":120000}"#
+ )
+ let watchedEpisode = try item(
+ #"{"ratingKey":"2","type":"episode","title":"Watched","viewCount":1}"#
+ )
+ let movie = try item(
+ #"{"ratingKey":"3","type":"movie","title":"Movie","viewCount":0}"#
+ )
+
+ #expect(!PlexEpisodeSpoilerPolicy.off.hidesSpoilers(for: unwatchedEpisode))
+ #expect(PlexEpisodeSpoilerPolicy.unwatchedEpisodes.hidesSpoilers(for: unwatchedEpisode))
+ #expect(!PlexEpisodeSpoilerPolicy.unwatchedEpisodes.hidesSpoilers(for: watchedEpisode))
+ #expect(PlexEpisodeSpoilerPolicy.allEpisodes.hidesSpoilers(for: watchedEpisode))
+ #expect(!PlexEpisodeSpoilerPolicy.allEpisodes.hidesSpoilers(for: movie))
+ }
+
+ @Test func protectedEpisodesExposeNeitherSummaryNorThumbnailPath() throws {
+ let episode = try item(
+ #"{"ratingKey":"1","type":"episode","title":"Episode","summary":"The reveal.","thumb":"/library/metadata/1/thumb"}"#
+ )
+
+ let protected = PlexEpisodeSpoilerPresentation(
+ item: episode,
+ policy: .unwatchedEpisodes
+ )
+ let visible = PlexEpisodeSpoilerPresentation(item: episode, policy: .off)
+
+ #expect(protected.isProtected)
+ #expect(protected.summary == nil)
+ #expect(protected.thumbnailPath == nil)
+ #expect(!visible.isProtected)
+ #expect(visible.summary == "The reveal.")
+ #expect(visible.thumbnailPath == "/library/metadata/1/thumb")
+ }
+
+ @MainActor
+ @Test func posterPrefetchRemainsAvailableWhileProtectedThumbnailPrefetchIsOmitted() throws {
+ let episode = try item(
+ #"{"ratingKey":"1","type":"episode","title":"Episode","thumb":"/library/metadata/1/thumb","grandparentThumb":"/library/metadata/10/thumb"}"#
+ )
+ let serverURL = try #require(URL(string: "https://plex.example"))
+ let clientContext = PlexClientContext(clientIdentifier: "client")
+
+ let thumbnailRequest = PlexMediaPosterCard.prefetchRequest(
+ for: episode,
+ serverURL: serverURL,
+ token: "token",
+ clientContext: clientContext,
+ artworkLayout: .automatic,
+ spoilerPolicy: .unwatchedEpisodes
+ )
+ let posterRequest = PlexMediaPosterCard.prefetchRequest(
+ for: episode,
+ serverURL: serverURL,
+ token: "token",
+ clientContext: clientContext,
+ artworkLayout: .poster,
+ spoilerPolicy: .unwatchedEpisodes
+ )
+
+ #expect(thumbnailRequest == nil)
+ #expect(posterRequest?.candidateURLs.map(\.path) == ["/library/metadata/10/thumb"])
+ }
+
+ @Test func posterIntentNeverFallsBackToAnEpisodeScreenshot() throws {
+ let episode = try item(
+ #"{"ratingKey":"1","type":"episode","title":"Episode","thumb":"/library/metadata/1/thumb"}"#
+ )
+
+ #expect(episode.posterArtworkPath == nil)
+ #expect(episode.nowPlayingArtworkPaths.isEmpty)
+ }
+
+ @Test func contentProposalPrefersTheEpisodePreviewAndDeduplicatesArtwork() throws {
+ let episode = try item(
+ #"{"ratingKey":"1","type":"episode","title":"Episode","thumb":"/episode/still","art":"/show/backdrop","parentThumb":"/season/poster","grandparentThumb":"/show/backdrop"}"#
+ )
+
+ #expect(episode.contentProposalArtworkPaths == [
+ "/episode/still",
+ "/show/backdrop",
+ "/season/poster",
+ ])
+ }
+
+ private func item(_ json: String) throws -> PlexMediaItem {
+ try JSONDecoder().decode(PlexMediaItem.self, from: Data(json.utf8))
+ }
+}
diff --git a/PlexBarTests/PlexGlobalSearchStoreTests.swift b/PlexBarTests/PlexGlobalSearchStoreTests.swift
new file mode 100644
index 0000000..ae58acc
--- /dev/null
+++ b/PlexBarTests/PlexGlobalSearchStoreTests.swift
@@ -0,0 +1,356 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+@MainActor
+struct PlexGlobalSearchStoreTests {
+ @Test func keepsDisplayedHubsVisibleUntilReplacementSearchCompletes() async throws {
+ let store = PlexGlobalSearchStore(debounceDuration: .zero)
+ let alienHubs = try hubs(title: "Alien")
+ let arrivalHubs = try hubs(title: "Arrival")
+ store.text = "Alien"
+ await store.update { _ in alienHubs }
+
+ let gate = GlobalSearchLoadGate()
+ store.text = "Arrival"
+ let update = Task {
+ await store.update { query in
+ #expect(query == "Arrival")
+ await gate.suspendLoad()
+ return arrivalHubs
+ }
+ }
+
+ await gate.waitUntilLoadStarts()
+ #expect(store.isSearching)
+ #expect(store.pendingQuery == "Arrival")
+ #expect(store.displayedQuery == "Alien")
+ #expect(store.visibleHubs.flatMap(\.metadata).map(\.title) == ["Alien"])
+
+ await gate.finishLoad()
+ await update.value
+
+ #expect(!store.isSearching)
+ #expect(store.displayedQuery == "Arrival")
+ #expect(store.visibleHubs.flatMap(\.metadata).map(\.title) == ["Arrival"])
+ }
+
+ @Test func supersededSearchCannotReplaceNewerResults() async throws {
+ let store = PlexGlobalSearchStore(debounceDuration: .zero)
+ let firstHubs = try hubs(title: "Alien")
+ let secondHubs = try hubs(title: "Aliens")
+ let gate = GlobalSearchLoadGate()
+ store.text = "Alien"
+
+ let firstUpdate = Task {
+ await store.update { _ in
+ await gate.suspendLoad()
+ return firstHubs
+ }
+ }
+ await gate.waitUntilLoadStarts()
+
+ store.text = "Aliens"
+ await store.update { query in
+ #expect(query == "Aliens")
+ return secondHubs
+ }
+ #expect(store.displayedQuery == "Aliens")
+
+ await gate.finishLoad()
+ await firstUpdate.value
+
+ #expect(store.displayedQuery == "Aliens")
+ #expect(store.visibleHubs.flatMap(\.metadata).map(\.title) == ["Aliens"])
+ #expect(!store.isSearching)
+ }
+
+ @Test func failedReplacementKeepsExistingResultsAndSurfacesTheError() async throws {
+ let store = PlexGlobalSearchStore(debounceDuration: .zero)
+ let existingHubs = try hubs(title: "Alien")
+ store.text = "Alien"
+ await store.update { _ in existingHubs }
+
+ store.text = "Arrival"
+ await store.update { _ in
+ throw PlexAPIError.invalidResponse
+ }
+
+ #expect(store.displayedQuery == "Alien")
+ #expect(store.visibleHubs.flatMap(\.metadata).map(\.title) == ["Alien"])
+ #expect(store.errorMessage != nil)
+ #expect(!store.isSearching)
+ }
+
+ @Test func clearingQueryResetsResultsErrorsAndNavigation() async throws {
+ let store = PlexGlobalSearchStore(debounceDuration: .zero)
+ store.text = "Alien"
+ await store.update { _ in try hubs(title: "Alien") }
+ let item = try #require(store.hubs.first?.metadata.first)
+ store.navigationPath = [.media(PlexMediaRoute(item: item))]
+
+ store.text = ""
+ await store.update { _ in
+ Issue.record("Clearing search must not issue a server request")
+ return []
+ }
+
+ #expect(store.text.isEmpty)
+ #expect(store.displayedQuery.isEmpty)
+ #expect(store.hubs.isEmpty)
+ #expect(store.navigationPath.isEmpty)
+ #expect(!store.hasSearched)
+ #expect(store.errorMessage == nil)
+ }
+
+ @Test func searchHubRouteRequiresAndMatchesTheExactReturnedKeyAndQuery() throws {
+ let hub = try #require(pagedHubs(query: "Alien").first)
+ let route = try #require(PlexSearchHubRoute(hub: hub, query: "Alien"))
+
+ #expect(route.hubKey == "/hubs/search?query=Alien&type=1")
+ #expect(route.matches(hub, query: "Alien"))
+ #expect(!route.matches(hub, query: "Arrival"))
+
+ let hubWithoutKey = try #require(hubs(title: "Alien").first)
+ #expect(PlexSearchHubRoute(hub: hubWithoutKey, query: "Alien") == nil)
+ }
+
+ @Test func showAllLoadsAndPaginatesUsingOnlyTheExactReturnedHubKey() async throws {
+ let store = PlexGlobalSearchStore(debounceDuration: .zero, pageSize: 2)
+ store.text = "Alien"
+ await store.update { _ in try pagedHubs(query: "Alien") }
+ let hub = try #require(store.hubs.first)
+ var requests: [(path: String, start: Int, size: Int)] = []
+
+ await store.loadItems(in: hub) { path, start, size in
+ requests.append((path, start, size))
+ return PlexMediaPage(
+ items: try [self.item(title: "Alien", ratingKey: "1"), self.item(title: "Aliens", ratingKey: "2")],
+ offset: start,
+ totalSize: 3
+ )
+ }
+
+ let lastItem = try #require(store.items(in: hub).last)
+ await store.loadMoreItemsIfNeeded(in: hub, currentItem: lastItem) { path, start, size in
+ requests.append((path, start, size))
+ return PlexMediaPage(
+ items: try [self.item(title: "Alien 3", ratingKey: "3")],
+ offset: start,
+ totalSize: 3
+ )
+ }
+
+ #expect(requests.map(\.path) == [
+ "/hubs/search?query=Alien&type=1",
+ "/hubs/search?query=Alien&type=1"
+ ])
+ #expect(requests.map(\.start) == [0, 2])
+ #expect(requests.map(\.size) == [2, 2])
+ #expect(store.items(in: hub).map(\.title) == ["Alien", "Aliens", "Alien 3"])
+ #expect(!store.hasMoreItems(in: hub))
+ }
+
+ @Test func failedShowAllRefreshKeepsExistingItemsAndExposesError() async throws {
+ let store = PlexGlobalSearchStore(debounceDuration: .zero)
+ store.text = "Alien"
+ await store.update { _ in try pagedHubs(query: "Alien") }
+ let hub = try #require(store.hubs.first)
+ await store.loadItems(in: hub) { _, _, _ in
+ PlexMediaPage(
+ items: try [self.item(title: "Alien", ratingKey: "1")],
+ offset: 0,
+ totalSize: 1
+ )
+ }
+
+ await store.loadItems(in: hub, forceRefresh: true) { _, _, _ in
+ throw PlexAPIError.invalidResponse
+ }
+
+ #expect(store.items(in: hub).map(\.title) == ["Alien"])
+ #expect(store.itemsErrorMessage(in: hub) != nil)
+ }
+
+ @Test func successfulReplacementQueryClearsExpandedItemsAndOldNavigation() async throws {
+ let store = PlexGlobalSearchStore(debounceDuration: .zero)
+ store.text = "Alien"
+ await store.update { _ in try pagedHubs(query: "Alien") }
+ let alienHub = try #require(store.hubs.first)
+ let alienRoute = try #require(PlexSearchHubRoute(hub: alienHub, query: "Alien"))
+ store.navigationPath = [.searchHub(alienRoute)]
+ await store.loadItems(in: alienHub) { _, _, _ in
+ PlexMediaPage(
+ items: try [self.item(title: "Alien", ratingKey: "1")],
+ offset: 0,
+ totalSize: 1
+ )
+ }
+
+ store.text = "Arrival"
+ await store.update { _ in try pagedHubs(query: "Arrival") }
+
+ #expect(store.displayedQuery == "Arrival")
+ #expect(store.itemsByHubPath.isEmpty)
+ #expect(store.navigationPath.isEmpty)
+ #expect(store.hub(for: alienRoute) == nil)
+ }
+
+ @Test func inFlightOldQueryPageCannotRepopulateSuccessfulReplacementState() async throws {
+ let store = PlexGlobalSearchStore(debounceDuration: .zero)
+ store.text = "Alien"
+ await store.update { _ in try pagedHubs(query: "Alien") }
+ let alienHub = try #require(store.hubs.first)
+ let stalePage = PlexMediaPage(
+ items: try [item(title: "Alien", ratingKey: "1")],
+ offset: 0,
+ totalSize: 1
+ )
+ let gate = GlobalSearchLoadGate()
+ let staleLoad = Task {
+ await store.loadItems(in: alienHub) { _, _, _ in
+ await gate.suspendLoad()
+ return stalePage
+ }
+ }
+ await gate.waitUntilLoadStarts()
+
+ store.text = "Arrival"
+ await store.update { _ in try pagedHubs(query: "Arrival") }
+ await gate.finishLoad()
+ await staleLoad.value
+
+ #expect(store.displayedQuery == "Arrival")
+ #expect(store.itemsByHubPath.isEmpty)
+ }
+
+ @Test func cachedMutationsAlsoUpdateExpandedSearchResults() async throws {
+ let store = PlexGlobalSearchStore(debounceDuration: .zero)
+ store.text = "Alien"
+ await store.update { _ in try pagedHubs(query: "Alien") }
+ let hub = try #require(store.hubs.first)
+ await store.loadItems(in: hub) { _, _, _ in
+ PlexMediaPage(
+ items: try [self.item(title: "Alien", ratingKey: "1", viewCount: 0, userRating: 2)],
+ offset: 0,
+ totalSize: 1
+ )
+ }
+
+ let refreshed = try item(
+ title: "Alien",
+ ratingKey: "1",
+ viewCount: 1,
+ userRating: 9
+ )
+ store.replaceCachedWatchedState(with: refreshed)
+ store.replaceCachedUserRating(with: refreshed)
+
+ let updated = try #require(store.items(in: hub).first)
+ #expect(updated.isWatched)
+ #expect(updated.userRating == 9)
+ }
+
+ private func hubs(title: String) throws -> [PlexHub] {
+ let data = Data(#"""
+ {
+ "MediaContainer": {
+ "Hub": [{
+ "hubIdentifier": "movie",
+ "title": "Movies",
+ "type": "movie",
+ "Metadata": [{
+ "ratingKey": "\#(title.lowercased())",
+ "key": "/library/metadata/\#(title.lowercased())",
+ "title": "\#(title)",
+ "type": "movie"
+ }]
+ }]
+ }
+ }
+ """#.utf8)
+ return try JSONDecoder().decode(PlexHubEnvelope.self, from: data).mediaContainer.hubs
+ }
+
+ private func pagedHubs(query: String) throws -> [PlexHub] {
+ let data = Data(#"""
+ {
+ "MediaContainer": {
+ "Hub": [{
+ "hubIdentifier": "movie",
+ "key": "/hubs/search?query=\#(query)&type=1",
+ "title": "Movies",
+ "type": "movie",
+ "size": 1,
+ "totalSize": 3,
+ "more": true,
+ "Metadata": [{
+ "ratingKey": "embedded-\#(query.lowercased())",
+ "key": "/library/metadata/embedded-\#(query.lowercased())",
+ "title": "\#(query)",
+ "type": "movie"
+ }]
+ }]
+ }
+ }
+ """#.utf8)
+ return try JSONDecoder().decode(PlexHubEnvelope.self, from: data).mediaContainer.hubs
+ }
+
+ private func item(
+ title: String,
+ ratingKey: String,
+ viewCount: Int? = nil,
+ userRating: Double? = nil
+ ) throws -> PlexMediaItem {
+ let viewCountField = viewCount.map { ",\"viewCount\":\($0)" } ?? ""
+ let userRatingField = userRating.map { ",\"userRating\":\($0)" } ?? ""
+ let data = Data(#"""
+ {
+ "MediaContainer": {
+ "Metadata": [{
+ "ratingKey": "\#(ratingKey)",
+ "key": "/library/metadata/\#(ratingKey)",
+ "title": "\#(title)",
+ "type": "movie"
+ \#(viewCountField)
+ \#(userRatingField)
+ }]
+ }
+ }
+ """#.utf8)
+ return try #require(
+ JSONDecoder().decode(PlexMediaEnvelope.self, from: data).mediaContainer.metadata.first
+ )
+ }
+}
+
+private actor GlobalSearchLoadGate {
+ private var hasStarted = false
+ private var startContinuation: CheckedContinuation?
+ private var loadContinuation: CheckedContinuation?
+
+ func suspendLoad() async {
+ hasStarted = true
+ startContinuation?.resume()
+ startContinuation = nil
+ await withCheckedContinuation { continuation in
+ loadContinuation = continuation
+ }
+ }
+
+ func waitUntilLoadStarts() async {
+ guard !hasStarted else {
+ return
+ }
+ await withCheckedContinuation { continuation in
+ startContinuation = continuation
+ }
+ }
+
+ func finishLoad() {
+ loadContinuation?.resume()
+ loadContinuation = nil
+ }
+}
diff --git a/PlexBarTests/PlexHistoryStoreTests.swift b/PlexBarTests/PlexHistoryStoreTests.swift
new file mode 100644
index 0000000..a047337
--- /dev/null
+++ b/PlexBarTests/PlexHistoryStoreTests.swift
@@ -0,0 +1,304 @@
+import PlexModels
+import Foundation
+import Testing
+
+@testable import PlexBar
+
+@MainActor
+@Suite(.serialized)
+struct PlexHistoryStoreTests {
+ @Test func mediaHistoryLoadsItsOwnViewerAndDeviceDirectory() async throws {
+ let suiteName = "PlexBarTests.mediaHistoryLoadsItsOwnViewerAndDeviceDirectory"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let credentials = PlexStoredCredentials(userToken: "account-token", serverToken: "server-token")
+ let settings = PlexSettingsStore(
+ defaults: defaults,
+ credentialStore: PlexMemoryCredentialStore(credentials: credentials),
+ initialCredentials: credentials
+ )
+ settings.selectedServerIdentifier = "server-id"
+ settings.selectedServerName = "Server"
+ settings.cachedConnectionURLString = "http://plex.local:32400"
+ settings.cachedConnectionKind = .local
+
+ let session = makeMockSession { request in
+ let url = try #require(request.url)
+ let response = try #require(
+ HTTPURLResponse(
+ url: url,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+
+ switch url.path {
+ case "/identity":
+ return (
+ response,
+ Data(
+ #"{"MediaContainer":{"claimed":true,"machineIdentifier":"server-id","version":"1.0.0"}}"#
+ .utf8)
+ )
+ case "/status/sessions/history/all":
+ return (
+ response,
+ Data(
+ #"{"MediaContainer":{"Metadata":[{"historyKey":"/status/sessions/history/9","key":"/library/metadata/500","ratingKey":"500","title":"Heat","type":"movie","viewedAt":1712452410,"accountID":7,"deviceID":12}]}}"#
+ .utf8)
+ )
+ case "/statistics/media":
+ return (
+ response,
+ Data(
+ #"{"MediaContainer":{"Account":[{"id":7,"name":"Taylor","thumb":"/accounts/7"}],"Device":[{"id":12,"name":"Living Room","platform":"tvOS"}]}}"#
+ .utf8)
+ )
+ default:
+ Issue.record("Unexpected request: \(request)")
+ throw URLError(.unsupportedURL)
+ }
+ }
+ let client = PlexAPIClient(session: session)
+ let resolver = PlexConnectionResolver(client: client, probeTimeoutInterval: 0.1)
+ let connectionStore = PlexConnectionStore(settings: settings, resolver: resolver)
+ let libraryStore = PlexLibraryStore(connectionStore: connectionStore, client: client)
+ let store = PlexHistoryStore(
+ connectionStore: connectionStore,
+ libraryStore: libraryStore,
+ client: client,
+ startsPolling: false
+ )
+ let item = try JSONDecoder().decode(
+ PlexMediaItem.self,
+ from: Data(#"{"ratingKey":"500","type":"movie","title":"Heat"}"#.utf8)
+ )
+
+ await store.loadMediaHistory(for: item)
+
+ #expect(store.mediaHistoryPresentation(for: item)?.items.count == 1)
+ #expect(store.accountsByID[7]?.name == "Taylor")
+ #expect(store.devicesByID[12]?.displayLine == "Living Room · tvOS")
+ }
+
+ @Test(arguments: [#""title": "Bob's Burgers","#, "", #""title": null,"#])
+ func preservesHistoryWhenAccountFetchFails(titleField: String) async throws {
+ let suiteName = "PlexBarTests.preservesHistoryWhenAccountFetchFails"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let settings = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+ settings.selectedServerIdentifier = "server-id"
+ settings.selectedServerName = "Server"
+ settings.serverToken = "server-token"
+ settings.cachedConnectionURLString = "http://plex.local:32400"
+ settings.cachedConnectionKind = .local
+
+ let session = makeMockSession { request in
+ let url = try #require(request.url)
+
+ if url.path == "/identity" {
+ let response = try #require(
+ HTTPURLResponse(
+ url: url,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ let data = try #require(
+ #"""
+ {
+ "MediaContainer": {
+ "claimed": true,
+ "machineIdentifier": "server-id",
+ "version": "1.0.0"
+ }
+ }
+ """#.data(using: .utf8))
+ return (response, data)
+ }
+
+ if url.path == "/status/sessions/history/all" {
+ let response = try #require(
+ HTTPURLResponse(
+ url: url,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ let data = try #require(
+ #"""
+ {
+ "MediaContainer": {
+ "Metadata": [
+ {
+ "historyKey": "/status/sessions/history/9",
+ "key": "/library/metadata/500",
+ "ratingKey": "500",
+ \#(titleField)
+ "type": "episode",
+ "grandparentTitle": "Bob's Burgers",
+ "viewedAt": 1712452410,
+ "accountID": 42
+ }
+ ]
+ }
+ }
+ """#.data(using: .utf8))
+ return (response, data)
+ }
+
+ if url.path == "/library/metadata/500" {
+ let response = try #require(
+ HTTPURLResponse(
+ url: url,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ let data = try #require(
+ #"""
+ {
+ "MediaContainer": {
+ "Metadata": [
+ {
+ "ratingKey": "500",
+ "type": "episode",
+ "grandparentRatingKey": "900",
+ "grandparentTitle": "Bob's Burgers",
+ "grandparentThumb": "/library/metadata/900/thumb/1715112830"
+ }
+ ]
+ }
+ }
+ """#.data(using: .utf8))
+ return (response, data)
+ }
+
+ if url.path == "/statistics/media" {
+ let response = try #require(
+ HTTPURLResponse(
+ url: url,
+ statusCode: 500,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, Data())
+ }
+
+ if url.path == "/library/sections/all" {
+ let response = try #require(
+ HTTPURLResponse(
+ url: url,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ let data = try #require(
+ #"""
+ {
+ "MediaContainer": {
+ "Directory": []
+ }
+ }
+ """#.data(using: .utf8))
+ return (response, data)
+ }
+
+ throw URLError(.unsupportedURL)
+ }
+
+ let resolver = PlexConnectionResolver(
+ client: PlexAPIClient(session: session),
+ probeTimeoutInterval: 0.1
+ )
+ let connectionStore = PlexConnectionStore(
+ settings: settings,
+ resolver: resolver
+ )
+ let libraryStore = PlexLibraryStore(
+ connectionStore: connectionStore,
+ client: PlexAPIClient(session: session)
+ )
+
+ let store = PlexHistoryStore(
+ connectionStore: connectionStore,
+ libraryStore: libraryStore,
+ client: PlexAPIClient(session: session)
+ )
+
+ store.refreshNow()
+ await waitForHistoryRefresh(on: store)
+
+ #expect(store.recentItems.count == 1)
+ #expect(store.recentItems.first?.title == (titleField.contains("Bob's Burgers") ? "Bob's Burgers" : "Untitled"))
+ #expect(store.totalPlayCount == 1)
+ #expect(store.distinctViewerCount == 1)
+ #expect(store.topUserEntries.count == 1)
+ #expect(store.topTitleEntries.first?.title == "Bob's Burgers")
+ #expect(store.accountsByID.isEmpty)
+ #expect(store.errorMessage == nil)
+ #expect(store.lastUpdated != nil)
+ }
+}
+
+@MainActor
+private func waitForHistoryRefresh(
+ on store: PlexHistoryStore,
+ timeoutNanoseconds: UInt64 = 2_000_000_000
+) async {
+ let deadline = DispatchTime.now().uptimeNanoseconds + timeoutNanoseconds
+
+ while DispatchTime.now().uptimeNanoseconds < deadline {
+ if !store.isLoading && !store.recentItems.isEmpty {
+ return
+ }
+
+ try? await Task.sleep(nanoseconds: 10_000_000)
+ }
+}
+
+private func makeMockSession(
+ handler: @escaping @Sendable (URLRequest) throws -> (HTTPURLResponse, Data)
+) -> URLSession {
+ HistoryStoreMockURLProtocol.requestHandler = handler
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.protocolClasses = [HistoryStoreMockURLProtocol.self]
+ return URLSession(configuration: configuration)
+}
+
+private final class HistoryStoreMockURLProtocol: URLProtocol, @unchecked Sendable {
+ nonisolated(unsafe) static var requestHandler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))?
+
+ override class func canInit(with request: URLRequest) -> Bool {
+ true
+ }
+
+ override class func canonicalRequest(for request: URLRequest) -> URLRequest {
+ request
+ }
+
+ override func startLoading() {
+ guard let handler = Self.requestHandler else {
+ client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
+ return
+ }
+
+ do {
+ let (response, data) = try handler(request)
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: data)
+ client?.urlProtocolDidFinishLoading(self)
+ } catch {
+ client?.urlProtocol(self, didFailWithError: error)
+ }
+ }
+
+ override func stopLoading() {}
+}
diff --git a/Tests/PlexBarTests/PlexHistoryTests.swift b/PlexBarTests/PlexHistoryTests.swift
similarity index 77%
rename from Tests/PlexBarTests/PlexHistoryTests.swift
rename to PlexBarTests/PlexHistoryTests.swift
index 4a1d73b..6b96da3 100644
--- a/Tests/PlexBarTests/PlexHistoryTests.swift
+++ b/PlexBarTests/PlexHistoryTests.swift
@@ -1,3 +1,4 @@
+import PlexModels
import Foundation
import Testing
@testable import PlexBar
@@ -23,8 +24,34 @@ import Testing
)
#expect(item.posterPath == "/library/metadata/show-thumb")
+ #expect(item.posterPath(spoilerPolicy: .allEpisodes) == "/library/metadata/show-thumb")
#expect(item.headline == "The X-Files")
- #expect(item.detailLine == "S3E13 • Syzygy")
+ #expect(item.detailLine == "S3 • E13 - Syzygy")
+}
+
+@Test func allEpisodeSpoilerPolicyDoesNotFallBackToHistoryScreenshots() async throws {
+ let item = PlexHistoryItem(
+ historyKey: "/status/sessions/history/12",
+ key: "/library/metadata/146",
+ ratingKey: "146",
+ title: "Episode",
+ type: "episode",
+ thumb: "/library/metadata/episode-thumb",
+ parentThumb: nil,
+ grandparentThumb: nil,
+ art: nil,
+ grandparentTitle: "Show",
+ parentTitle: "Season 1",
+ parentIndex: 1,
+ index: 1,
+ originallyAvailableAt: nil,
+ viewedAt: Date(timeIntervalSince1970: 1_700_000_000),
+ accountID: 42
+ )
+
+ #expect(item.posterPath == "/library/metadata/episode-thumb")
+ #expect(item.posterPath(spoilerPolicy: .unwatchedEpisodes) == "/library/metadata/episode-thumb")
+ #expect(item.posterPath(spoilerPolicy: .allEpisodes) == nil)
}
@Test func aggregatesTopTitleChartsBySeriesNameForEpisodes() async throws {
@@ -598,6 +625,98 @@ import Testing
#expect(watcher == "alexcaro3")
}
+@Test func resolvesPlaybackDeviceAndPresentsItsExactServerFields() {
+ let item = PlexHistoryItem(
+ historyKey: "/status/sessions/history/22",
+ key: "/library/metadata/22",
+ ratingKey: "22",
+ title: "The Immortal Man",
+ type: "movie",
+ thumb: nil,
+ parentThumb: nil,
+ grandparentThumb: nil,
+ art: nil,
+ grandparentTitle: nil,
+ parentTitle: nil,
+ parentIndex: nil,
+ index: nil,
+ originallyAvailableAt: "2025-01-01",
+ viewedAt: Date(timeIntervalSince1970: 1_700_000_000),
+ accountID: 77,
+ deviceID: 12
+ )
+ let device = PlexHistoryDevice(id: 12, name: "Living Room", platform: "tvOS")
+
+ #expect(item.playbackDevice(using: [12: device]) == device)
+ #expect(device.displayLine == "Living Room · tvOS")
+ #expect(PlexHistoryDevice(id: 13, name: "Safari", platform: "safari").displayLine == "Safari")
+}
+
+@Test func mediaHistoryRowsPresentContentViewerAndDeviceFacts() {
+ let item = PlexHistoryItem(
+ historyKey: "/status/sessions/history/23",
+ key: "/library/metadata/23",
+ ratingKey: "23",
+ title: "Good News About Hell",
+ type: "episode",
+ thumb: "/library/metadata/23/thumb",
+ parentThumb: nil,
+ grandparentThumb: "/library/metadata/20/thumb",
+ art: nil,
+ grandparentTitle: "Severance",
+ parentTitle: "Season 1",
+ parentIndex: 1,
+ index: 1,
+ originallyAvailableAt: "2022-02-18",
+ viewedAt: Date(timeIntervalSince1970: 1_700_000_000),
+ accountID: 77,
+ deviceID: 12
+ )
+
+ let presentation = PlexMediaHistoryRowPresentation(
+ item: item,
+ account: PlexAccount(id: 77, name: "Taylor", thumb: nil),
+ device: PlexHistoryDevice(id: 12, name: "Living Room", platform: "tvOS")
+ )
+
+ #expect(presentation.title == "Severance")
+ #expect(presentation.subtitle == "S1 • E1 - Good News About Hell")
+ #expect(presentation.viewerLine == "Watched by Taylor")
+ #expect(presentation.deviceLine == "Living Room · tvOS")
+ #expect(presentation.contextLine == "Watched by Taylor • Living Room · tvOS")
+}
+
+@Test func mediaHistoryRowsExplicitlyPresentMissingViewerContext() {
+ let item = PlexHistoryItem(
+ historyKey: "/status/sessions/history/24",
+ key: "/library/metadata/24",
+ ratingKey: "24",
+ title: "Heat",
+ type: "movie",
+ thumb: nil,
+ parentThumb: nil,
+ grandparentThumb: nil,
+ art: nil,
+ grandparentTitle: nil,
+ parentTitle: nil,
+ parentIndex: nil,
+ index: nil,
+ originallyAvailableAt: "1995-12-15",
+ viewedAt: nil,
+ accountID: nil
+ )
+
+ let presentation = PlexMediaHistoryRowPresentation(
+ item: item,
+ account: nil,
+ device: nil
+ )
+
+ #expect(presentation.title == "Heat")
+ #expect(presentation.subtitle == "1995")
+ #expect(presentation.contextLine == "Viewer unavailable")
+}
+
@Test func ranksTopUsersByPlayCountThenName() async throws {
let firstAlexPlay = PlexHistoryItem(
historyKey: "/status/sessions/history/1",
@@ -742,3 +861,74 @@ import Testing
#expect(recentViewers.first?.lastPlayedTitle == "Collateral")
#expect(recentViewers.last?.name == "Jordan")
}
+
+@Test func contentHistoryRequiresASupportedTypeAndNumericPMSMetadataIdentity() throws {
+ let decoder = JSONDecoder()
+ let movie = try decoder.decode(
+ PlexMediaItem.self,
+ from: Data(#"{"ratingKey":"42","type":"movie","title":"Heat"}"#.utf8)
+ )
+ let show = try decoder.decode(
+ PlexMediaItem.self,
+ from: Data(#"{"ratingKey":"84","type":"show","title":"Severance"}"#.utf8)
+ )
+ let track = try decoder.decode(
+ PlexMediaItem.self,
+ from: Data(#"{"ratingKey":"126","type":"track","title":"Track"}"#.utf8)
+ )
+ let clip = try decoder.decode(
+ PlexMediaItem.self,
+ from: Data(#"{"ratingKey":"168","type":"clip","title":"Trailer"}"#.utf8)
+ )
+ let externalIdentity = try decoder.decode(
+ PlexMediaItem.self,
+ from: Data(#"{"ratingKey":"provider-movie-42","type":"movie","title":"External"}"#.utf8)
+ )
+
+ #expect(movie.supportsPlaybackHistory)
+ #expect(show.supportsPlaybackHistory)
+ #expect(track.supportsPlaybackHistory)
+ #expect(!clip.supportsPlaybackHistory)
+ #expect(!externalIdentity.supportsPlaybackHistory)
+}
+
+@Test func historyNavigationTargetsTheWatchedItemAndAggregatedSeries() throws {
+ let decoder = JSONDecoder()
+ let movie = try decoder.decode(
+ PlexHistoryItem.self,
+ from: Data(#"{"ratingKey":"42","key":"/library/metadata/42","type":"movie","title":"Heat"}"#.utf8)
+ )
+ let episode = try decoder.decode(
+ PlexHistoryItem.self,
+ from: Data(#"{"ratingKey":"901","key":"/library/metadata/901","type":"episode","title":"Good News About Hell","grandparentTitle":"Severance"}"#.utf8)
+ )
+ let seriesByEpisodeID = [
+ "901": PlexHistorySeriesIdentity(
+ id: "900",
+ title: "Severance",
+ posterPath: "/library/metadata/900/thumb"
+ )
+ ]
+
+ #expect(movie.mediaRoute?.ratingKey == "42")
+ #expect(movie.mediaRoute?.itemID == "42")
+ #expect(episode.mediaRoute?.ratingKey == "901")
+
+ let movieChart = try #require(PlexHistoryAnalytics.topTitleEntries(
+ from: [movie],
+ accountsByID: [:],
+ seriesByEpisodeID: [:],
+ limit: 5
+ ).first)
+ let showChart = try #require(PlexHistoryAnalytics.topTitleEntries(
+ from: [episode],
+ accountsByID: [:],
+ seriesByEpisodeID: seriesByEpisodeID,
+ limit: 5
+ ).first)
+
+ #expect(movieChart.mediaRoute?.ratingKey == "42")
+ #expect(showChart.title == "Severance")
+ #expect(showChart.mediaRoute?.ratingKey == "900")
+ #expect(showChart.mediaRoute?.itemID == "900")
+}
diff --git a/PlexBarTests/PlexHomeTests.swift b/PlexBarTests/PlexHomeTests.swift
new file mode 100644
index 0000000..a448304
--- /dev/null
+++ b/PlexBarTests/PlexHomeTests.swift
@@ -0,0 +1,144 @@
+import PlexModels
+#if os(macOS)
+import Foundation
+import Synchronization
+import Testing
+@testable import PlexBar
+
+@Suite(.serialized)
+struct PlexHomeTests {
+ @Test func unifiedFeedReplacesBothLegacyRowsAndPreservesServerOrderAndPaging() async throws {
+ let fixture = Fixture()
+ defer { fixture.close() }
+ let hubs = try await fixture.home()
+ #expect(hubs.map(\.hubIdentifier) == ["continueWatching", "recent.movies", "recent.tv"])
+ let hub = try #require(hubs.first)
+ #expect(hub.metadata.map(\.ratingKey) == ["next", "paused"])
+ #expect(hub.isContinueWatching)
+ #expect(hub.prefersPosterArtwork)
+ #expect(hub.title == "Continuer")
+ #expect(hub.more)
+ #expect(hub.totalSize == 3)
+ let path = try #require(hub.key)
+ let page = try await fixture.page(path: path)
+ #expect(page.items.map(\.ratingKey) == ["last"])
+ #expect(page.totalSize == 3)
+ let requests = HomeProtocol.requests.withLock { $0 }
+ let feed = try #require(requests.first { $0.url?.path == "/custom/continue" })
+ let query = try #require(URLComponents(url: feed.url!, resolvingAgainstBaseURL: false)?.queryItems)
+ #expect(query.contains(URLQueryItem(name: "source", value: "library")))
+ #expect(query.contains(URLQueryItem(name: "count", value: "20")))
+ #expect(feed.value(forHTTPHeaderField: "X-Plex-Token") == "test-token")
+ let expanded = try #require(requests.first { $0.url?.path == "/custom/continue/items" })
+ #expect(expanded.value(forHTTPHeaderField: "X-Plex-Container-Start") == "2")
+ #expect(expanded.url?.query == "source=library")
+ #expect(!requests.contains { $0.url?.path == "/hubs/home/onDeck" })
+ }
+
+ @Test func emptyUnifiedFeedDoesNotResurrectLegacyItems() async throws {
+ let fixture = Fixture(continuation: #"{"MediaContainer":{"Hub":[{"hubIdentifier":"continueWatching","title":"Continue Watching","Metadata":[]}]}}"#)
+ defer { fixture.close() }
+ let hubs = try await fixture.home()
+ #expect(hubs.map(\.hubIdentifier) == ["recent.movies", "recent.tv"])
+ }
+
+ @Test func failedUnifiedFeedSurfacesTheError() async throws {
+ let fixture = Fixture(status: 503)
+ defer { fixture.close() }
+ do {
+ _ = try await fixture.home()
+ Issue.record("Expected unified feed failure")
+ } catch {
+ #expect(error.localizedDescription.contains("503"))
+ }
+ }
+
+ @Test func missingUnifiedCapabilityDoesNotRequestLegacyHome() async throws {
+ let fixture = Fixture(advertisesContinuation: false)
+ defer { fixture.close() }
+ do {
+ _ = try await fixture.home()
+ Issue.record("Expected missing Continue Watching capability")
+ } catch let error as PlexAPIError {
+ guard case .missingLibraryContinueWatchingFeature = error else {
+ Issue.record("Unexpected error: \(error)")
+ return
+ }
+ }
+ #expect(!HomeProtocol.requests.withLock { $0.contains { $0.url?.path == "/custom/promoted" } })
+ }
+
+ @Test func splitResponseFromUnifiedEndpointIsRejected() async throws {
+ let fixture = Fixture(continuation: HomeProtocol.promoted)
+ defer { fixture.close() }
+ do {
+ _ = try await fixture.home()
+ Issue.record("Expected malformed unified response failure")
+ } catch let error as PlexAPIError {
+ guard case .invalidResponse = error else {
+ Issue.record("Unexpected error: \(error)")
+ return
+ }
+ }
+ }
+
+ private struct Fixture {
+ let session: URLSession
+ let advertisesContinuation: Bool
+ init(continuation: String = HomeProtocol.unified, status: Int = 200, advertisesContinuation: Bool = true) {
+ self.advertisesContinuation = advertisesContinuation
+ HomeProtocol.requests.withLock { $0 = [] }
+ HomeProtocol.response.withLock { $0 = (continuation, status, advertisesContinuation) }
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.protocolClasses = [HomeProtocol.self]
+ session = URLSession(configuration: configuration)
+ }
+ var connection: PlexConnectionConfiguration {
+ PlexConnectionConfiguration(serverURL: URL(string: "https://plex.test")!, token: "test-token",
+ clientContext: PlexClientContext(clientIdentifier: "home-tests"))
+ }
+ func home() async throws -> [PlexHub] {
+ let client = PlexAPIClient(session: session)
+ let endpoints = try await client.fetchLibraryProviderEndpoints(using: connection)
+ return try await client.fetchHomeHubs(endpoints: endpoints, using: connection)
+ }
+ func page(path: String) async throws -> PlexMediaPage {
+ try await PlexAPIClient(session: session).fetchMediaPage(contentPath: path, using: connection, start: 2)
+ }
+ func close() { session.invalidateAndCancel() }
+ }
+}
+
+private final class HomeProtocol: URLProtocol, @unchecked Sendable {
+ static let requests = Mutex<[URLRequest]>([])
+ static let response = Mutex<(String, Int, Bool)>((unified, 200, true))
+ static let unified = #"{"MediaContainer":{"Hub":[{"hubIdentifier":"continueWatching","title":"Continuer","key":"/custom/continue/items?source=library","more":true,"size":2,"totalSize":3,"Metadata":[{"ratingKey":"next","type":"episode","title":"Next episode"},{"ratingKey":"paused","type":"movie","title":"Paused movie","viewOffset":60000}]}]}}"#
+ static let promoted = #"{"MediaContainer":{"Hub":[{"hubIdentifier":"recent.movies","title":"Movies","Metadata":[{"ratingKey":"paused","type":"movie","title":"Paused movie"}]},{"hubIdentifier":"home.continue","title":"Old Continue Watching","Metadata":[{"ratingKey":"paused","type":"movie","title":"Paused movie"}]},{"hubIdentifier":"home.onDeck","title":"On Deck","Metadata":[{"ratingKey":"next","type":"episode","title":"Next episode"},{"ratingKey":"stale","type":"episode","title":"Excluded episode"}]},{"hubIdentifier":"continueWatching","title":"Promoted duplicate","Metadata":[{"ratingKey":"stale","type":"episode","title":"Excluded episode"}]},{"hubIdentifier":"recent.tv","title":"TV","Metadata":[{"ratingKey":"next","type":"episode","title":"Next episode"}]}]}}"#
+ override class func canInit(with request: URLRequest) -> Bool { true }
+ override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
+ override func startLoading() {
+ Self.requests.withLock { $0.append(request) }
+ let state = Self.response.withLock { $0 }
+ let json: String
+ var status = 200
+ switch request.url?.path {
+ case "/media/providers":
+ let continuation = state.2 ? #",{"type":"ContinueWatching","key":"/custom/continue?source=library"}"# : ""
+ json = #"{"MediaContainer":{"MediaProvider":[{"identifier":"com.plexapp.plugins.library","Feature":[{"type":"promoted","key":"/custom/promoted?source=library"}\#(continuation)]}]}}"#
+ case "/custom/promoted": json = Self.promoted
+ case "/custom/continue": (json, status) = (state.0, state.1)
+ case "/custom/continue/items":
+ json = #"{"MediaContainer":{"offset":2,"totalSize":3,"Metadata":[{"ratingKey":"last","type":"episode","title":"Last episode"}]}}"#
+ default:
+ json = "{}"
+ status = 404
+ }
+ guard let url = request.url,
+ let response = HTTPURLResponse(url: url, statusCode: status, httpVersion: nil, headerFields: nil) else { return }
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: Data(json.utf8))
+ client?.urlProtocolDidFinishLoading(self)
+ }
+ override func stopLoading() {}
+}
+#endif
diff --git a/PlexBarTests/PlexImageDecoderTests.swift b/PlexBarTests/PlexImageDecoderTests.swift
new file mode 100644
index 0000000..c318343
--- /dev/null
+++ b/PlexBarTests/PlexImageDecoderTests.swift
@@ -0,0 +1,54 @@
+import CoreGraphics
+import Foundation
+import ImageIO
+import Testing
+import UniformTypeIdentifiers
+@testable import PlexBar
+
+@Suite
+struct PlexImageDecoderTests {
+ @Test func decodesAndDownsamplesAwayFromTheCallingActor() async throws {
+ let data = try pngData(width: 400, height: 200)
+
+ let original = try #require(await PlexImageDecoder.decodeCGImage(from: data))
+ #expect(original.image.width == 400)
+ #expect(original.image.height == 200)
+
+ let thumbnail = try #require(await PlexImageDecoder.decodeCGImage(
+ from: data,
+ maximumPixelSize: 100
+ ))
+ #expect(thumbnail.image.width == 100)
+ #expect(thumbnail.image.height == 50)
+ }
+
+ @Test func rejectsMalformedImageData() async {
+ let image = await PlexImageDecoder.decodeCGImage(from: Data("not an image".utf8))
+ #expect(image == nil)
+ }
+
+ private func pngData(width: Int, height: Int) throws -> Data {
+ let context = try #require(CGContext(
+ data: nil,
+ width: width,
+ height: height,
+ bitsPerComponent: 8,
+ bytesPerRow: width * 4,
+ space: CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ ))
+ context.setFillColor(CGColor(red: 0.1, green: 0.3, blue: 0.8, alpha: 1))
+ context.fill(CGRect(x: 0, y: 0, width: width, height: height))
+ let image = try #require(context.makeImage())
+ let data = NSMutableData()
+ let destination = try #require(CGImageDestinationCreateWithData(
+ data,
+ UTType.png.identifier as CFString,
+ 1,
+ nil
+ ))
+ CGImageDestinationAddImage(destination, image, nil)
+ #expect(CGImageDestinationFinalize(destination))
+ return data as Data
+ }
+}
diff --git a/PlexBarTests/PlexJWTTests.swift b/PlexBarTests/PlexJWTTests.swift
new file mode 100644
index 0000000..8f5a6eb
--- /dev/null
+++ b/PlexBarTests/PlexJWTTests.swift
@@ -0,0 +1,101 @@
+import CryptoKit
+import Foundation
+import Testing
+@testable import PlexBar
+
+struct PlexJWTTests {
+ @Test func publicJWKUsesPlexEd25519Contract() throws {
+ let identity = try PlexDeviceSigningIdentity.generate(keyID: "device-key")
+
+ let pinJWK = try identity.publicJWK(includeUse: false)
+ let migrationJWK = try identity.publicJWK(includeUse: true)
+
+ #expect(pinJWK.kty == "OKP")
+ #expect(pinJWK.crv == "Ed25519")
+ #expect(pinJWK.alg == "EdDSA")
+ #expect(pinJWK.kid == "device-key")
+ #expect(pinJWK.use == nil)
+ #expect(try decodeBase64URL(pinJWK.x).count == 32)
+ #expect(migrationJWK.use == "sig")
+ }
+
+ @Test func signedDeviceJWTContainsExactClaimsAndValidSignature() throws {
+ let identity = try PlexDeviceSigningIdentity.generate(keyID: "device-key")
+ let issuedAt = Date(timeIntervalSince1970: 2_000_000_000)
+
+ let token = try identity.signedDeviceJWT(
+ clientIdentifier: "stable-client-id",
+ nonce: "plex-nonce",
+ scope: PlexAccountJWTManager.requestedScope,
+ issuedAt: issuedAt
+ )
+
+ let segments = token.split(separator: ".", omittingEmptySubsequences: false)
+ #expect(segments.count == 3)
+ let header = try jsonObject(from: String(segments[0]))
+ let claims = try jsonObject(from: String(segments[1]))
+ #expect(header["alg"] as? String == "EdDSA")
+ #expect(header["kid"] as? String == "device-key")
+ #expect(header["typ"] as? String == "JWT")
+ #expect(claims["nonce"] as? String == "plex-nonce")
+ #expect(claims["scope"] as? String == "username,email,friendly_name")
+ #expect(claims["aud"] as? String == "plex.tv")
+ #expect(claims["iss"] as? String == "stable-client-id")
+ #expect(claims["iat"] as? Int == 2_000_000_000)
+ #expect(claims["exp"] as? Int == 2_000_000_300)
+
+ let jwk = try identity.publicJWK(includeUse: false)
+ let publicKey = try Curve25519.Signing.PublicKey(rawRepresentation: decodeBase64URL(jwk.x))
+ let signingInput = Data("\(segments[0]).\(segments[1])".utf8)
+ let signature = try decodeBase64URL(String(segments[2]))
+ #expect(publicKey.isValidSignature(signature, for: signingInput))
+ }
+
+ @Test func accountJWTDecodesExpiration() throws {
+ let expiration = 4_102_444_800
+ let token = try makeAccountJWT(expiration: expiration)
+
+ let accountToken = try PlexAccountToken(token: token)
+
+ #expect(accountToken == .jwt(expiresAt: Date(timeIntervalSince1970: TimeInterval(expiration))))
+ }
+
+ @Test func malformedThreePartAccountTokenIsRejected() {
+ #expect(throws: PlexJWTError.self) {
+ _ = try PlexAccountToken(token: "header.not-json.signature")
+ }
+ }
+
+ @Test func opaqueAccountTokenIsClassifiedAsLegacy() throws {
+ #expect(try PlexAccountToken(token: "legacy-token") == .legacy)
+ }
+}
+
+private func makeAccountJWT(expiration: Int) throws -> String {
+ let header = try JSONSerialization.data(withJSONObject: ["alg": "EdDSA", "typ": "JWT"])
+ let payload = try JSONSerialization.data(withJSONObject: ["exp": expiration])
+ return "\(encodeBase64URL(header)).\(encodeBase64URL(payload)).test-signature"
+}
+
+private func jsonObject(from encodedSegment: String) throws -> [String: Any] {
+ let data = try decodeBase64URL(encodedSegment)
+ return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
+}
+
+private func decodeBase64URL(_ value: String) throws -> Data {
+ var base64 = value
+ .replacingOccurrences(of: "-", with: "+")
+ .replacingOccurrences(of: "_", with: "/")
+ let remainder = base64.count % 4
+ if remainder != 0 {
+ base64.append(String(repeating: "=", count: 4 - remainder))
+ }
+ return try #require(Data(base64Encoded: base64))
+}
+
+private func encodeBase64URL(_ data: Data) -> String {
+ data.base64EncodedString()
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: "")
+}
diff --git a/PlexBarTests/PlexLibraryPresentationStoreTests.swift b/PlexBarTests/PlexLibraryPresentationStoreTests.swift
new file mode 100644
index 0000000..48ffd84
--- /dev/null
+++ b/PlexBarTests/PlexLibraryPresentationStoreTests.swift
@@ -0,0 +1,87 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+@MainActor
+@Suite(.serialized)
+struct PlexLibraryPresentationStoreTests {
+ @Test func synchronizeRetainsIndependentPresentationStateForActiveLibraries() throws {
+ let store = PlexLibraryPresentationStore()
+ store.synchronize(libraryIDs: ["movies", "shows"])
+
+ let movies = try #require(store.state(for: "movies"))
+ let shows = try #require(store.state(for: "shows"))
+ let movie = try decodeItem(ratingKey: "101", title: "Movie")
+ movies.navigationPath = [.media(PlexMediaRoute(item: movie))]
+ movies.searchStore.text = "science fiction"
+ movies.scrollPosition.scrollTo(id: movie.id, anchor: .top)
+
+ store.synchronize(libraryIDs: ["movies", "shows"])
+
+ let retainedMovies = try #require(store.state(for: "movies"))
+ let retainedShows = try #require(store.state(for: "shows"))
+ #expect(retainedMovies === movies)
+ #expect(retainedShows === shows)
+ #expect(retainedMovies.navigationPath == [.media(PlexMediaRoute(item: movie))])
+ #expect(retainedMovies.searchStore.text == "science fiction")
+ #expect(retainedMovies.scrollPosition.viewID(type: String.self) == movie.id)
+ #expect(retainedShows.navigationPath.isEmpty)
+ #expect(retainedShows.searchStore.text.isEmpty)
+ }
+
+ @Test func synchronizeRemovesLibrariesThatAreNoLongerAvailable() throws {
+ let store = PlexLibraryPresentationStore()
+ store.synchronize(libraryIDs: ["movies", "shows"])
+ let shows = try #require(store.state(for: "shows"))
+
+ store.synchronize(libraryIDs: ["shows"])
+
+ #expect(store.state(for: "movies") == nil)
+ #expect(store.state(for: "shows") === shows)
+ }
+
+ @Test func removeAllInvalidatesEveryServerScopedPresentationState() {
+ let store = PlexLibraryPresentationStore()
+ store.synchronize(libraryIDs: ["movies", "shows"])
+
+ store.removeAll()
+
+ #expect(store.state(for: "movies") == nil)
+ #expect(store.state(for: "shows") == nil)
+ }
+
+ @Test func mediaRouteUsesLightweightListIdentityAndRatingKey() throws {
+ let first = try decodeItem(
+ ratingKey: "404",
+ title: "First Copy",
+ playlistItemID: "playlist-entry-1"
+ )
+ let second = try decodeItem(
+ ratingKey: "404",
+ title: "Second Copy",
+ playlistItemID: "playlist-entry-2"
+ )
+ let route = PlexMediaRoute(item: first)
+
+ #expect(route.matches(first))
+ #expect(!route.matches(second))
+ #expect(route.ratingKey == "404")
+ #expect(route.itemID == "playlist-item:playlist-entry-1")
+ }
+
+ private func decodeItem(
+ ratingKey: String,
+ title: String,
+ playlistItemID: String? = nil
+ ) throws -> PlexMediaItem {
+ var object: [String: Any] = [
+ "ratingKey": ratingKey,
+ "title": title,
+ "type": "movie",
+ ]
+ object["playlistItemID"] = playlistItemID
+ let data = try JSONSerialization.data(withJSONObject: object)
+ return try JSONDecoder().decode(PlexMediaItem.self, from: data)
+ }
+}
diff --git a/PlexBarTests/PlexLibrarySearchStoreTests.swift b/PlexBarTests/PlexLibrarySearchStoreTests.swift
new file mode 100644
index 0000000..fc75001
--- /dev/null
+++ b/PlexBarTests/PlexLibrarySearchStoreTests.swift
@@ -0,0 +1,237 @@
+import Foundation
+import Testing
+@testable import PlexBar
+
+@MainActor
+struct PlexLibrarySearchStoreTests {
+ @Test func searchRemainsInProgressUntilTheServerLoadCompletes() async {
+ let store = PlexLibrarySearchStore(debounceDuration: .zero)
+ let gate = SearchLoadGate()
+ store.text = "Alien"
+
+ let update = Task {
+ await store.update { query in
+ #expect(query == "Alien")
+ await gate.suspendLoad()
+ }
+ }
+
+ await gate.waitUntilLoadStarts()
+ #expect(store.isSearching)
+ #expect(store.pendingQuery == "Alien")
+ #expect(store.displayedQuery.isEmpty)
+
+ await gate.finishLoad()
+ await update.value
+
+ #expect(!store.isSearching)
+ #expect(store.displayedQuery == "Alien")
+ }
+
+ @Test func clearingSearchReportsProgressAndKeepsTheDisplayedResultsStable() async {
+ let store = PlexLibrarySearchStore(debounceDuration: .zero)
+ store.text = "Alien"
+ await store.update { _ in }
+
+ let gate = SearchLoadGate()
+ store.text = ""
+ let update = Task {
+ await store.update { query in
+ #expect(query.isEmpty)
+ await gate.suspendLoad()
+ }
+ }
+
+ await gate.waitUntilLoadStarts()
+ #expect(store.isSearching)
+ #expect(store.pendingQuery == "")
+ #expect(store.displayedQuery == "Alien")
+
+ await gate.finishLoad()
+ await update.value
+
+ #expect(!store.isSearching)
+ #expect(store.displayedQuery.isEmpty)
+ }
+
+ @Test func aSupersededSearchCannotReplaceNewerResults() async {
+ let store = PlexLibrarySearchStore(debounceDuration: .zero)
+ let firstLoad = SearchLoadGate()
+ store.text = "Alien"
+
+ let firstUpdate = Task {
+ await store.update { _ in
+ await firstLoad.suspendLoad()
+ }
+ }
+ await firstLoad.waitUntilLoadStarts()
+
+ store.text = "Aliens"
+ await store.update { query in
+ #expect(query == "Aliens")
+ }
+ #expect(store.displayedQuery == "Aliens")
+
+ await firstLoad.finishLoad()
+ await firstUpdate.value
+
+ #expect(store.displayedQuery == "Aliens")
+ #expect(!store.isSearching)
+ }
+
+ @Test func browseOptionChangeKeepsDisplayedResultsStableUntilLoadCompletes() async throws {
+ let store = PlexLibrarySearchStore(debounceDuration: .zero)
+ let sort = try JSONDecoder().decode(
+ PlexLibrarySortDefinition.self,
+ from: Data(
+ #"{"key":"addedAt","descKey":"addedAt:desc","title":"Date Added","defaultDirection":"desc"}"#.utf8
+ )
+ )
+ store.selectSort(sort)
+ let gate = SearchLoadGate()
+
+ let update = Task {
+ await store.update { query, options in
+ #expect(query.isEmpty)
+ #expect(options.sort?.queryValue == "addedAt:desc")
+ await gate.suspendLoad()
+ }
+ }
+
+ await gate.waitUntilLoadStarts()
+ #expect(store.isUpdating)
+ #expect(store.displayedOptions == .default)
+ #expect(store.pendingOptions?.sort?.queryValue == "addedAt:desc")
+
+ await gate.finishLoad()
+ await update.value
+
+ #expect(!store.isUpdating)
+ #expect(store.displayedOptions.sort?.queryValue == "addedAt:desc")
+ }
+
+ @Test func supersededBrowseOptionsCannotReplaceNewerResults() async throws {
+ let store = PlexLibrarySearchStore(debounceDuration: .zero)
+ let definitionData = Data(
+ #"{"key":"addedAt","descKey":"addedAt:desc","title":"Date Added","defaultDirection":"desc"}"#.utf8
+ )
+ let sort = try JSONDecoder().decode(PlexLibrarySortDefinition.self, from: definitionData)
+ let firstLoad = SearchLoadGate()
+ store.selectSort(sort)
+
+ let firstUpdate = Task {
+ await store.update { _, _ in
+ await firstLoad.suspendLoad()
+ }
+ }
+ await firstLoad.waitUntilLoadStarts()
+
+ store.selectSortDirection(.ascending, definition: sort)
+ await store.update { _, options in
+ #expect(options.sort?.queryValue == "addedAt")
+ }
+
+ await firstLoad.finishLoad()
+ await firstUpdate.value
+
+ #expect(store.displayedOptions.sort?.queryValue == "addedAt")
+ #expect(!store.isUpdating)
+ }
+
+ @Test func valueFilterChangeKeepsDisplayedResultsStableUntilLoadCompletes() async throws {
+ let store = PlexLibrarySearchStore(debounceDuration: .zero)
+ let filter = try JSONDecoder().decode(
+ PlexLibraryFilterDefinition.self,
+ from: Data(
+ #"{"filter":"genre","filterType":"string","key":"/library/sections/2/genre","title":"Genre"}"#.utf8
+ )
+ )
+ let action = PlexLibraryFilterValue(
+ filterID: "genre",
+ queryName: "genre",
+ queryValue: "190",
+ title: "Action"
+ )
+ store.setSelectedValues([action], for: filter)
+ let gate = SearchLoadGate()
+
+ let update = Task {
+ await store.update { query, options in
+ #expect(query.isEmpty)
+ #expect(options.valueSelections(for: "genre") == [action])
+ await gate.suspendLoad()
+ }
+ }
+
+ await gate.waitUntilLoadStarts()
+ #expect(store.isUpdating)
+ #expect(store.displayedOptions.valueFilterSelections.isEmpty)
+ #expect(store.pendingOptions?.valueSelections(for: "genre") == [action])
+
+ await gate.finishLoad()
+ await update.value
+
+ #expect(!store.isUpdating)
+ #expect(store.displayedOptions.valueSelections(for: "genre") == [action])
+ }
+
+ @Test func clearFiltersRemovesBooleanAndValueSelectionsTogether() throws {
+ let store = PlexLibrarySearchStore(debounceDuration: .zero)
+ let filter = try JSONDecoder().decode(
+ PlexLibraryFilterDefinition.self,
+ from: Data(
+ #"{"filter":"genre","filterType":"string","key":"/library/sections/2/genre","title":"Genre"}"#.utf8
+ )
+ )
+ store.setBooleanFilter(
+ try JSONDecoder().decode(
+ PlexLibraryFilterDefinition.self,
+ from: Data(
+ #"{"filter":"unwatched","filterType":"boolean","key":"/library/sections/2/unwatched","title":"Unwatched"}"#.utf8
+ )
+ ),
+ isEnabled: true
+ )
+ store.setSelectedValues([
+ PlexLibraryFilterValue(
+ filterID: "genre",
+ queryName: "genre",
+ queryValue: "190",
+ title: "Action"
+ )
+ ], for: filter)
+
+ #expect(store.hasSelectedFilters)
+ store.clearFilters()
+ #expect(!store.hasSelectedFilters)
+ }
+}
+
+private actor SearchLoadGate {
+ private var hasStarted = false
+ private var startContinuation: CheckedContinuation?
+ private var loadContinuation: CheckedContinuation?
+
+ func suspendLoad() async {
+ hasStarted = true
+ startContinuation?.resume()
+ startContinuation = nil
+ await withCheckedContinuation { continuation in
+ loadContinuation = continuation
+ }
+ }
+
+ func waitUntilLoadStarts() async {
+ guard !hasStarted else {
+ return
+ }
+ await withCheckedContinuation { continuation in
+ startContinuation = continuation
+ }
+ }
+
+ func finishLoad() {
+ loadContinuation?.resume()
+ loadContinuation = nil
+ }
+}
diff --git a/Tests/PlexBarTests/PlexLoginItemServiceTests.swift b/PlexBarTests/PlexLoginItemServiceTests.swift
similarity index 100%
rename from Tests/PlexBarTests/PlexLoginItemServiceTests.swift
rename to PlexBarTests/PlexLoginItemServiceTests.swift
diff --git a/PlexBarTests/PlexMainNavigationStoreTests.swift b/PlexBarTests/PlexMainNavigationStoreTests.swift
new file mode 100644
index 0000000..f84a5b9
--- /dev/null
+++ b/PlexBarTests/PlexMainNavigationStoreTests.swift
@@ -0,0 +1,65 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+@MainActor
+struct PlexMainNavigationStoreTests {
+ @Test func showMediaOpensTheExactItemFromTheHomeNavigationRoot() throws {
+ let item = try mediaItem(ratingKey: "42", title: "Current Item")
+ let store = PlexMainNavigationStore()
+ store.selection = .history
+ store.homeNavigationPath = [.media(PlexMediaRoute(ratingKey: "old")!)]
+
+ store.showMedia(item)
+
+ #expect(store.selection == .home)
+ #expect(store.homeNavigationPath == [.media(PlexMediaRoute(item: item))])
+ }
+
+ @Test func serverChangeClearsEveryPrimaryNavigationPath() throws {
+ let route = PlexNavigationRoute.media(
+ PlexMediaRoute(item: try mediaItem(ratingKey: "42", title: "Item"))
+ )
+ let store = PlexMainNavigationStore()
+ store.selection = .library("movies")
+ store.homeNavigationPath = [route]
+ store.historyNavigationPath = [route]
+ store.collectionsNavigationPath = [route]
+ store.playlistsNavigationPath = [route]
+
+ store.resetForServerChange()
+
+ #expect(store.selection == .home)
+ #expect(store.homeNavigationPath.isEmpty)
+ #expect(store.historyNavigationPath.isEmpty)
+ #expect(store.collectionsNavigationPath.isEmpty)
+ #expect(store.playlistsNavigationPath.isEmpty)
+ }
+
+ @Test func playerLibraryHandoffRequiresTheExactActiveServer() {
+ #expect(PlexPlayerLibraryHandoffPolicy.canOpen(
+ playbackServerIdentifier: "server-a",
+ browserServerIdentifier: "server-a"
+ ))
+ #expect(!PlexPlayerLibraryHandoffPolicy.canOpen(
+ playbackServerIdentifier: "server-a",
+ browserServerIdentifier: "server-b"
+ ))
+ #expect(!PlexPlayerLibraryHandoffPolicy.canOpen(
+ playbackServerIdentifier: nil,
+ browserServerIdentifier: "server-a"
+ ))
+ #expect(!PlexPlayerLibraryHandoffPolicy.canOpen(
+ playbackServerIdentifier: "server-a",
+ browserServerIdentifier: " "
+ ))
+ }
+
+ private func mediaItem(ratingKey: String, title: String) throws -> PlexMediaItem {
+ try JSONDecoder().decode(
+ PlexMediaItem.self,
+ from: Data(#"{"ratingKey":"\#(ratingKey)","title":"\#(title)","type":"movie"}"#.utf8)
+ )
+ }
+}
diff --git a/PlexBarTests/PlexMediaArtworkPresentationTests.swift b/PlexBarTests/PlexMediaArtworkPresentationTests.swift
new file mode 100644
index 0000000..70a0da7
--- /dev/null
+++ b/PlexBarTests/PlexMediaArtworkPresentationTests.swift
@@ -0,0 +1,40 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+struct PlexMediaArtworkPresentationTests {
+ @Test func episodeBrowserAndHomeUseTheirDifferentPlexArtworkContracts() throws {
+ let episode = try item(#"{"ratingKey":"1","type":"episode","title":"Episode","thumb":"/episode.jpg","parentThumb":"/season.jpg","grandparentThumb":"/show.jpg"}"#)
+ let browser = PlexMediaArtworkPresentation(item: episode)
+ let home = PlexMediaArtworkPresentation(item: episode, layout: .poster)
+ #expect(browser.shape == .landscape)
+ #expect(browser.path == "/episode.jpg")
+ #expect(home.shape == .poster)
+ #expect(home.path == "/show.jpg")
+ }
+
+ @Test func seasonCardsKeepTheSeasonPosterRatherThanRepeatingTheSeriesPoster() throws {
+ let season = try item(#"{"ratingKey":"2","type":"season","title":"Season 2","thumb":"/season-2.jpg","parentThumb":"/show.jpg"}"#)
+ let card = PlexMediaArtworkPresentation(item: season)
+ #expect(card.shape == .poster)
+ #expect(card.path == "/season-2.jpg")
+ }
+
+ @Test func missingSeriesPosterDoesNotCropAnEpisodeThumbnailIntoAPoster() throws {
+ let episode = try item(#"{"ratingKey":"1","type":"episode","title":"Episode","thumb":"/episode.jpg"}"#)
+ #expect(PlexMediaArtworkPresentation(item: episode, layout: .poster).path == nil)
+ }
+
+ @Test(arguments: ["artist", "album", "track", "photo", "photoalbum", "collection", "playlist"])
+ func squareMediaKeepsMacOSArtworkShape(type: String) throws {
+ let media = try item(#"{"ratingKey":"3","type":"\#(type)","title":"Media","thumb":"/art.jpg"}"#)
+ let card = PlexMediaArtworkPresentation(item: media)
+ #expect(card.shape == .square)
+ #expect(card.path == "/art.jpg")
+ }
+
+ private func item(_ json: String) throws -> PlexMediaItem {
+ try JSONDecoder().decode(PlexMediaItem.self, from: Data(json.utf8))
+ }
+}
diff --git a/PlexBarTests/PlexMediaBrowseRequestTests.swift b/PlexBarTests/PlexMediaBrowseRequestTests.swift
new file mode 100644
index 0000000..8a9ceb6
--- /dev/null
+++ b/PlexBarTests/PlexMediaBrowseRequestTests.swift
@@ -0,0 +1,726 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+@Suite(.serialized)
+struct PlexMediaBrowseRequestTests {
+ @Test func tvBrowseDefinitionAcceptsSeasonsWithoutFilters() async throws {
+ let session = makeBrowseMediaMockSession { request in
+ let response = try #require(HTTPURLResponse(
+ url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil
+ ))
+ switch request.url?.path {
+ case "/library/sections/4":
+ return (response, Data(#"""
+ {"MediaContainer":{"Type":[
+ {"key":"/library/sections/4/all?type=2","type":"show","title":"TV Shows","Filter":[],"Sort":[]},
+ {"key":"/library/sections/4/all?type=3","type":"season","title":"Seasons","Sort":[
+ {"default":"asc","defaultDirection":"asc","descKey":"show.titleSort:desc,index","key":"show.titleSort,index","title":"Show"}
+ ]},
+ {"key":"/library/sections/4/all?type=4","type":"episode","title":"Episodes","Filter":[],"Sort":[]}
+ ]}}
+ """#.utf8))
+ case "/library/sections/4/filters":
+ return (response, libraryFiltersData())
+ case "/library/sections/4/sorts":
+ return (response, librarySortsData())
+ default:
+ Issue.record("Unexpected browse-definition request: \(request)")
+ throw URLError(.unsupportedURL)
+ }
+ }
+
+ let definition = try await PlexAPIClient(session: session).fetchLibraryBrowseDefinition(
+ sectionPath: "/library/sections/4",
+ contentPath: "/library/sections/4/all",
+ using: try configuration
+ )
+
+ #expect(definition.types.map(\.type) == ["show", "season", "episode"])
+ #expect(definition.booleanFilters.map(\.id) == ["unwatched"])
+ let seasons = try definition.selecting("/library/sections/4/all?type=3")
+ #expect(seasons.filters.isEmpty)
+ #expect(seasons.sorts.map(\.id) == ["show.titleSort,index"])
+ }
+
+ @Test func episodeIdentifierIsLimitedToEpisodeMetadata() throws {
+ let show = try JSONDecoder().decode(
+ PlexMediaItem.self,
+ from: Data(#"{"ratingKey":"7","type":"show","title":"Show","index":"1","year":"2024"}"#.utf8)
+ )
+ let episode = try JSONDecoder().decode(
+ PlexMediaItem.self,
+ from: Data(
+ #"{"ratingKey":"8","type":"episode","title":"Pilot","index":"2","parentIndex":"1"}"#.utf8
+ )
+ )
+
+ #expect(show.episodeIdentifier == nil)
+ #expect(show.factsLine == "2024")
+ #expect(episode.episodeIdentifier == "S1, E2")
+ }
+
+ @Test func browsingRuntimeRoundsToMinutesAndOmitsSeconds() throws {
+ let episode = try JSONDecoder().decode(
+ PlexMediaItem.self,
+ from: Data(
+ #"{"ratingKey":"8","type":"episode","title":"Episode","duration":5022000}"#.utf8
+ )
+ )
+
+ let runtime = episode.formattedDuration?
+ .replacingOccurrences(of: "\u{202F}", with: " ")
+
+ #expect(runtime == "1 hr 24 min")
+ #expect(runtime?.contains("sec") == false)
+ }
+
+ @Test func librarySearchUsesServerSideTitleContainsQuery() async throws {
+ let capture = RequestCapture()
+ let session = makeBrowseMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: ["X-Plex-Container-Total-Size": "1"]
+ ))
+ return (response, Data(#"{"MediaContainer":{"Metadata":[]}}"#.utf8))
+ }
+
+ _ = try await PlexAPIClient(session: session).fetchMediaPage(
+ libraryID: "2",
+ using: try configuration,
+ start: 0,
+ size: 25,
+ searchQuery: "Twin Peaks"
+ )
+
+ let request = try #require(capture.request)
+ let components = try requestComponents(request)
+ #expect(components.queryItems?.contains { $0.name == "title" && $0.value == "Twin Peaks" } == true)
+ #expect(components.queryItems?.contains { $0.name == "sort" } != true)
+ }
+
+ @Test func browseDefinitionUsesTheAdvertisedLibraryRouteAndDocumentedDescriptors() async throws {
+ let capture = RequestCapture()
+ let session = makeBrowseMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ switch request.url?.path {
+ case "/provider/sections/2":
+ return (response, Data(#"{"MediaContainer":{"Type":[]}}"#.utf8))
+ case "/provider/sections/2/filters":
+ return (response, libraryFiltersData())
+ case "/provider/sections/2/sorts":
+ return (response, librarySortsData())
+ default:
+ Issue.record("Unexpected browse-definition request: \(request)")
+ throw URLError(.unsupportedURL)
+ }
+ }
+
+ let definition = try await PlexAPIClient(session: session).fetchLibraryBrowseDefinition(
+ sectionPath: "/provider/sections/2",
+ contentPath: "/provider/sections/2/all?type=1&source=library",
+ using: try configuration
+ )
+
+ #expect(definition.contentPath == "/provider/sections/2/all?type=1&source=library")
+ #expect(definition.booleanFilters.map(\.id) == ["unwatched"])
+ #expect(definition.sorts.map(\.title) == ["Name", "Date Added"])
+ #expect(definition.sorts.first?.defaultSelectionDirection == .ascending)
+ #expect(definition.sorts.last?.selection()?.queryValue == "addedAt:desc")
+ #expect(Set(capture.requests.compactMap(\.url?.path)) == [
+ "/provider/sections/2",
+ "/provider/sections/2/filters",
+ "/provider/sections/2/sorts",
+ ])
+ }
+
+ @Test func browseDefinitionPreservesTheAdvertisedSectionQueryForDescriptors() async throws {
+ let capture = RequestCapture()
+ let session = makeBrowseMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+
+ switch request.url?.path {
+ case "/provider/sections/7":
+ return (response, Data(#"""
+ {"MediaContainer":{"Type":[
+ {"key":"/provider/sections/7/all?type=8&source=library","type":"artist","title":"Artists","Filter":[],"Sort":[]},
+ {"key":"/provider/sections/7/all?type=9&source=library","type":"album","title":"Albums","Filter":[],"Sort":[]}
+ ]}}
+ """#.utf8))
+ case "/provider/sections/7/filters":
+ return (response, libraryFiltersData())
+ case "/provider/sections/7/sorts":
+ return (response, librarySortsData())
+ default:
+ Issue.record("Unexpected browse-definition request: \(request)")
+ throw URLError(.unsupportedURL)
+ }
+ }
+
+ let definition = try await PlexAPIClient(session: session).fetchLibraryBrowseDefinition(
+ sectionPath: "/provider/sections/7?source=library",
+ contentPath: "/provider/sections/7/all?type=1&source=library",
+ using: try configuration
+ )
+
+ #expect(definition.contentPath == "/provider/sections/7/all?type=1&source=library")
+ #expect(definition.filters.map(\.id) == ["genre", "unwatched"])
+ #expect(definition.sorts.map(\.id) == ["titleSort", "addedAt"])
+ #expect(capture.requests.allSatisfy { request in
+ let query = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems
+ let expected = [URLQueryItem(name: "source", value: "library")]
+ + (request.url?.path == "/provider/sections/7"
+ ? [URLQueryItem(name: "includeDetails", value: "1")] : [])
+ return query == expected
+ })
+ let albums = try definition.selecting("/provider/sections/7/all?type=9&source=library")
+ #expect(definition.types.map(\.title) == ["Artists", "Albums"])
+ #expect(albums.contentPath == "/provider/sections/7/all?type=9&source=library")
+ #expect(albums.filters.isEmpty)
+ #expect(albums.sorts.isEmpty)
+ #expect(throws: PlexAPIError.self) {
+ try definition.selecting("/unadvertised/path")
+ }
+ }
+
+ @Test func filterValuesUseAdvertisedEndpointAndPreserveServerQueryPairs() async throws {
+ let capture = RequestCapture()
+ let session = makeBrowseMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, Data(#"""
+ {
+ "MediaContainer": {
+ "Directory": [
+ { "id": 190, "filter": "genre=190", "tag": "Action" },
+ { "key": 98, "title": "Adventure" }
+ ]
+ }
+ }
+ """#.utf8))
+ }
+ let filter = try JSONDecoder().decode(
+ PlexLibraryFilterDefinition.self,
+ from: Data(
+ #"{"filter":"genre","filterType":"string","key":"/library/sections/2/genre?type=1","title":"Genre"}"#.utf8
+ )
+ )
+
+ let values = try await PlexAPIClient(session: session).fetchLibraryFilterValues(
+ for: filter,
+ using: try configuration
+ )
+
+ let components = try requestComponents(try #require(capture.request))
+ #expect(components.path == "/library/sections/2/genre")
+ #expect(components.queryItems == [URLQueryItem(name: "type", value: "1")])
+ #expect(values == [
+ PlexLibraryFilterValue(
+ filterID: "genre",
+ queryName: "genre",
+ queryValue: "190",
+ title: "Action"
+ ),
+ PlexLibraryFilterValue(
+ filterID: "genre",
+ queryName: "genre",
+ queryValue: "98",
+ title: "Adventure"
+ ),
+ ])
+ }
+
+ @Test func browseRequestPreservesTypeKeyAndUsesExactServerDescriptors() async throws {
+ let capture = RequestCapture()
+ let session = makeBrowseMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: ["X-Plex-Container-Total-Size": "0"]
+ ))
+ return (response, Data(#"{"MediaContainer":{"Metadata":[]}}"#.utf8))
+ }
+ let sort = try #require(try decodedBrowseDefinition().sorts.last)
+ let options = PlexLibraryBrowseOptions(
+ sort: sort.selection(direction: .descending),
+ enabledBooleanFilterIDs: ["hdr", "unwatched"],
+ valueFilterSelections: [
+ PlexLibraryFilterValue(
+ filterID: "genre",
+ queryName: "genre",
+ queryValue: "190",
+ title: "Action"
+ ),
+ PlexLibraryFilterValue(
+ filterID: "genre",
+ queryName: "genre",
+ queryValue: "98",
+ title: "Adventure"
+ ),
+ PlexLibraryFilterValue(
+ filterID: "year",
+ queryName: "year",
+ queryValue: "2024",
+ title: "2024"
+ ),
+ ]
+ )
+
+ _ = try await PlexAPIClient(session: session).fetchMediaPage(
+ contentPath: "/library/sections/2/all?type=1&includeGuids=1&sort=serverDefault",
+ using: try configuration,
+ start: 0,
+ size: 25,
+ searchQuery: "Alien",
+ browseOptions: options
+ )
+
+ let request = try #require(capture.request)
+ let queryItems = try requestComponents(request).queryItems ?? []
+ #expect(queryItems.filter { $0.name == "sort" } == [
+ URLQueryItem(name: "sort", value: "addedAt:desc")
+ ])
+ #expect(queryItems.contains(URLQueryItem(name: "type", value: "1")))
+ #expect(queryItems.contains(URLQueryItem(name: "includeGuids", value: "1")))
+ #expect(queryItems.contains(URLQueryItem(name: "title", value: "Alien")))
+ #expect(queryItems.contains(URLQueryItem(name: "hdr", value: "1")))
+ #expect(queryItems.contains(URLQueryItem(name: "unwatched", value: "1")))
+ #expect(queryItems.filter { $0.name == "genre" } == [
+ URLQueryItem(name: "genre", value: "190,98")
+ ])
+ #expect(queryItems.contains(URLQueryItem(name: "year", value: "2024")))
+ }
+
+ @Test func sortWithoutServerDescendingKeyDoesNotInventOne() throws {
+ let data = Data(#"{"key":"random","title":"Random","defaultDirection":"desc"}"#.utf8)
+ let sort = try JSONDecoder().decode(PlexLibrarySortDefinition.self, from: data)
+
+ #expect(sort.selection() == nil)
+ #expect(sort.selection(direction: .ascending)?.queryValue == "random")
+ }
+
+ @Test func childrenRequestRespectsSkipChildrenAndDecodesHierarchyFields() async throws {
+ let capture = RequestCapture()
+ let session = makeBrowseMediaMockSession { request in
+ capture.record(request)
+ return try hierarchyResponse(for: request)
+ }
+ let show = try JSONDecoder().decode(PlexMediaItem.self, from: skippedSeasonShowData())
+
+ let page = try await PlexAPIClient(session: session).fetchMediaChildren(
+ of: show,
+ using: try configuration,
+ start: 50,
+ size: 25
+ )
+
+ let request = try #require(capture.request)
+ #expect(request.url?.path == "/library/metadata/7/grandchildren")
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Container-Start") == "50")
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Container-Size") == "25")
+ #expect(page.items.first?.parentRatingKey == "70")
+ #expect(page.items.first?.grandparentRatingKey == "7")
+ #expect(page.items.first?.parentThumb == "/library/metadata/70/thumb")
+ }
+
+ @Test func skipChildrenPreservesServerQueryItemsWhenSelectingGrandchildren() throws {
+ let show = try JSONDecoder().decode(
+ PlexMediaItem.self,
+ from: Data(#"{"ratingKey":"7","key":"/library/metadata/7/children?includeGuids=1&excludeAllLeaves=0","type":"show","title":"Show","skipChildren":"1"}"#.utf8)
+ )
+
+ let path = try #require(show.childrenPath)
+ let components = try #require(URLComponents(string: path))
+ #expect(components.path == "/library/metadata/7/grandchildren")
+ #expect(components.queryItems == [
+ URLQueryItem(name: "includeGuids", value: "1"),
+ URLQueryItem(name: "excludeAllLeaves", value: "0"),
+ ])
+ }
+
+ @Test func photoLibrariesUseThePhotoAlbumPivotForHierarchicalBrowsing() {
+ #expect(PlexLibraryType.photo.metadataTypeID == 14)
+ #expect(PlexLibraryType.photoAlbum.metadataTypeID == 14)
+ }
+
+ @Test func photoAlbumsFollowTheirExactServerReturnedChildrenKey() async throws {
+ let capture = RequestCapture()
+ let session = makeBrowseMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: ["X-Plex-Container-Total-Size": "1"]
+ ))
+ return (response, Data(#"{"MediaContainer":{"Metadata":[]}}"#.utf8))
+ }
+ let album = try JSONDecoder().decode(
+ PlexMediaItem.self,
+ from: Data(
+ #"{"ratingKey":"44","key":"/library/metadata/44/children?includeRelated=0","type":"photoalbum","title":"Vacation"}"#.utf8
+ )
+ )
+
+ _ = try await PlexAPIClient(session: session).fetchMediaChildren(
+ of: album,
+ using: try configuration,
+ start: 0,
+ size: 50
+ )
+
+ let request = try #require(capture.request)
+ let components = try requestComponents(request)
+ #expect(album.hasChildren)
+ #expect(components.path == "/library/metadata/44/children")
+ #expect(components.queryItems == [URLQueryItem(name: "includeRelated", value: "0")])
+ }
+
+ @Test func returnedPlexKeyPreservesItsQueryItems() async throws {
+ let capture = RequestCapture()
+ let session = makeBrowseMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, Data(#"{"MediaContainer":{"Metadata":[]}}"#.utf8))
+ }
+ let album = try JSONDecoder().decode(
+ PlexMediaItem.self,
+ from: Data(#"""
+ {
+ "ratingKey": "265",
+ "key": "/library/metadata/265/children?includeGuids=1",
+ "type": "album",
+ "title": "Mandatory Fun"
+ }
+ """#.utf8)
+ )
+
+ _ = try await PlexAPIClient(session: session).fetchMediaChildren(
+ of: album,
+ using: try configuration
+ )
+
+ let request = try #require(capture.request)
+ let components = try requestComponents(request)
+ #expect(components.queryItems?.contains { $0.name == "includeGuids" && $0.value == "1" } == true)
+ }
+
+ @MainActor
+ @Test func filterValueCacheIsScopedToTheResolvedServerConnection() async throws {
+ let suiteName = "PlexBarTests.filterValueCacheIsScopedToConnection"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let counter = FilterValueRequestCounter()
+ let session = makeBrowseMediaMockSession { request in
+ counter.record()
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ let title = request.url?.host == "plex.remote" ? "Remote" : "Local"
+ return (response, Data(#"{"MediaContainer":{"Directory":[{"key":"1","title":"\#(title)"}]}}"#.utf8))
+ }
+ let settings = PlexSettingsStore(
+ defaults: defaults,
+ credentialStore: PlexMemoryCredentialStore(
+ credentials: PlexStoredCredentials(userToken: "user-token", serverToken: "server-token")
+ ),
+ initialCredentials: PlexStoredCredentials(userToken: "user-token", serverToken: "server-token")
+ )
+ settings.selectedServerIdentifier = "server-id"
+ settings.selectedServerName = "Server"
+ let connectionStore = PlexConnectionStore(settings: settings)
+ connectionStore.activeConnection = PlexResolvedConnection(
+ serverID: "server-id",
+ url: try #require(URL(string: "https://plex.local:32400")),
+ kind: .local,
+ validatedAt: Date()
+ )
+ let store = PlexBrowserStore(
+ connectionStore: connectionStore,
+ client: PlexAPIClient(session: session)
+ )
+ let filter = try JSONDecoder().decode(
+ PlexLibraryFilterDefinition.self,
+ from: Data(
+ #"{"filter":"genre","filterType":"string","key":"/library/sections/2/genre","title":"Genre"}"#.utf8
+ )
+ )
+
+ await store.loadFilterValues(for: filter)
+ await store.loadFilterValues(for: filter)
+ #expect(counter.count == 1)
+ #expect(store.filterValues(for: filter).map(\.title) == ["Local"])
+
+ connectionStore.activeConnection = PlexResolvedConnection(
+ serverID: "server-id",
+ url: try #require(URL(string: "https://plex.remote:32400")),
+ kind: .remote,
+ validatedAt: Date()
+ )
+ #expect(store.filterValues(for: filter).isEmpty)
+
+ await store.loadFilterValues(for: filter)
+ #expect(counter.count == 2)
+ #expect(store.filterValues(for: filter).map(\.title) == ["Remote"])
+
+ settings.serverToken = "another-server-token"
+ #expect(store.filterValues(for: filter).isEmpty)
+
+ await store.loadFilterValues(for: filter)
+ #expect(counter.count == 3)
+ #expect(store.filterValues(for: filter).map(\.title) == ["Remote"])
+ }
+
+ private var configuration: PlexConnectionConfiguration {
+ get throws {
+ PlexConnectionConfiguration(
+ serverURL: try #require(PlexURLBuilder.normalizeServerURL("https://plex.local:32400")),
+ token: "server-token",
+ clientContext: PlexClientContext(clientIdentifier: "client-123")
+ )
+ }
+ }
+
+ private func requestComponents(_ request: URLRequest) throws -> URLComponents {
+ try #require(request.url.flatMap { URLComponents(url: $0, resolvingAgainstBaseURL: false) })
+ }
+}
+
+private func decodedBrowseDefinition() throws -> PlexLibraryBrowseDefinition {
+ let decoded = try JSONDecoder().decode(
+ PlexLibraryBrowseEnvelope.self,
+ from: libraryBrowseDefinitionData()
+ )
+ let directory = try #require(decoded.mediaContainer.directories.last)
+ let contentPath = try #require(directory.key)
+ return PlexLibraryBrowseDefinition(
+ contentPath: contentPath,
+ filters: directory.filters,
+ sorts: directory.sorts
+ )
+}
+
+private func libraryBrowseDefinitionData() -> Data {
+ Data(#"""
+ {
+ "MediaContainer": {
+ "Directory": [
+ {
+ "key": "/library/sections/2/all?type=2",
+ "type": 2,
+ "Filter": [],
+ "Sort": []
+ },
+ {
+ "key": "/library/sections/2/all?type=1",
+ "type": "1",
+ "Filter": [
+ {
+ "filter": "unwatched",
+ "filterType": "boolean",
+ "key": "/library/sections/2/unwatched",
+ "title": "Unwatched"
+ },
+ {
+ "filter": "genre",
+ "filterType": "string",
+ "key": "/library/sections/2/genre",
+ "title": "Genre"
+ }
+ ],
+ "Sort": [
+ {
+ "default": "asc",
+ "defaultDirection": "asc",
+ "descKey": "titleSort:desc",
+ "key": "titleSort",
+ "title": "Name"
+ },
+ {
+ "defaultDirection": "desc",
+ "descKey": "addedAt:desc",
+ "key": "addedAt",
+ "title": "Date Added"
+ }
+ ]
+ }
+ ]
+ }
+ }
+ """#.utf8)
+}
+
+private func libraryFiltersData() -> Data {
+ Data(#"""
+ {
+ "MediaContainer": {
+ "Directory": [
+ {
+ "filter": "genre",
+ "filterType": "string",
+ "key": "/library/sections/7/genre",
+ "title": "Genre"
+ },
+ {
+ "filter": "unwatched",
+ "filterType": "boolean",
+ "key": "/library/sections/7/unwatched",
+ "title": "Unwatched"
+ }
+ ]
+ }
+ }
+ """#.utf8)
+}
+
+private func librarySortsData() -> Data {
+ Data(#"""
+ {
+ "MediaContainer": {
+ "Directory": [
+ {
+ "default": "asc",
+ "defaultDirection": "asc",
+ "descKey": "titleSort:desc",
+ "key": "titleSort",
+ "title": "Name"
+ },
+ {
+ "defaultDirection": "desc",
+ "descKey": "addedAt:desc",
+ "key": "addedAt",
+ "title": "Date Added"
+ }
+ ]
+ }
+ }
+ """#.utf8)
+}
+
+private func hierarchyResponse(for request: URLRequest) throws -> (HTTPURLResponse, Data) {
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: ["X-Plex-Container-Total-Size": "1"]
+ ))
+ let data = Data(#"""
+ {
+ "MediaContainer": {
+ "Metadata": [{
+ "ratingKey": "701",
+ "parentRatingKey": 70,
+ "grandparentRatingKey": "7",
+ "title": "Episode One",
+ "type": "episode",
+ "index": "1",
+ "parentIndex": "1",
+ "parentThumb": "/library/metadata/70/thumb",
+ "grandparentThumb": "/library/metadata/7/thumb"
+ }]
+ }
+ }
+ """#.utf8)
+ return (response, data)
+}
+
+private func skippedSeasonShowData() -> Data {
+ Data(#"""
+ {
+ "ratingKey": "7",
+ "key": "/library/metadata/7/children",
+ "type": "show",
+ "title": "One Season Show",
+ "skipChildren": "1"
+ }
+ """#.utf8)
+}
+
+private func makeBrowseMediaMockSession(
+ handler: @escaping @Sendable (URLRequest) throws -> (HTTPURLResponse, Data)
+) -> URLSession {
+ BrowseMediaMockURLProtocol.requestHandler = handler
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.protocolClasses = [BrowseMediaMockURLProtocol.self]
+ return URLSession(configuration: configuration)
+}
+
+private final class BrowseMediaMockURLProtocol: URLProtocol, @unchecked Sendable {
+ nonisolated(unsafe) static var requestHandler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))?
+
+ override static func canInit(with request: URLRequest) -> Bool {
+ true
+ }
+
+ override static func canonicalRequest(for request: URLRequest) -> URLRequest {
+ request
+ }
+
+ override func startLoading() {
+ guard let handler = Self.requestHandler else {
+ client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
+ return
+ }
+
+ do {
+ let (response, data) = try handler(request)
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: data)
+ client?.urlProtocolDidFinishLoading(self)
+ } catch {
+ client?.urlProtocol(self, didFailWithError: error)
+ }
+ }
+
+ override func stopLoading() {}
+}
+
+private final class FilterValueRequestCounter: @unchecked Sendable {
+ private let lock = NSLock()
+ private var value = 0
+
+ var count: Int {
+ lock.lock()
+ defer { lock.unlock() }
+ return value
+ }
+
+ func record() {
+ lock.lock()
+ value += 1
+ lock.unlock()
+ }
+}
diff --git a/PlexBarTests/PlexMediaExtrasTests.swift b/PlexBarTests/PlexMediaExtrasTests.swift
new file mode 100644
index 0000000..6ef7126
--- /dev/null
+++ b/PlexBarTests/PlexMediaExtrasTests.swift
@@ -0,0 +1,483 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+@Suite(.serialized)
+struct PlexMediaExtrasTests {
+ @MainActor
+ @Test func anExtraDoesNotRequestItsOwnExtras() async throws {
+ let suiteName = "PlexBarTests.extraDoesNotHaveExtras"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ let store = try makeStore(defaults: defaults, scenario: MediaExtrasScenario())
+ let clip = try JSONDecoder().decode(PlexMediaItem.self, from: Data(
+ #"{"ratingKey":"42","type":"clip","title":"Trailer"}"#.utf8
+ ))
+ await store.loadMediaExtras(for: clip) { _ in
+ Issue.record("An extra must not request the unsupported nested extras endpoint.")
+ return []
+ }
+ #expect(store.mediaExtras(for: clip).isEmpty)
+ #expect(store.mediaExtrasErrorMessage(for: clip) == nil)
+ }
+
+ @Test func documentedPrimaryExtraKeyProducesOnlyServerSupportedDetailActions() throws {
+ let movie = try JSONDecoder().decode(
+ PlexMediaItem.self,
+ from: Data(#"{"ratingKey":"42","type":"movie","title":"Heat","primaryExtraKey":"/library/metadata/420"}"#.utf8)
+ )
+ let track = try JSONDecoder().decode(
+ PlexMediaItem.self,
+ from: Data(#"{"ratingKey":"84","type":"track","title":"Song","primaryExtraKey":"/library/metadata/840"}"#.utf8)
+ )
+ let show = try JSONDecoder().decode(
+ PlexMediaItem.self,
+ from: Data(#"{"ratingKey":"126","type":"show","title":"Show","primaryExtraKey":"/library/metadata/1260"}"#.utf8)
+ )
+ let movieWithoutPrimaryExtra = try JSONDecoder().decode(
+ PlexMediaItem.self,
+ from: Data(#"{"ratingKey":"168","type":"movie","title":"Movie"}"#.utf8)
+ )
+
+ #expect(movie.primaryExtraKey == "/library/metadata/420")
+ #expect(movie.primaryExtraActionTitle == "Trailer")
+ #expect(track.primaryExtraActionTitle == "Music Video")
+ #expect(show.primaryExtraActionTitle == nil)
+ #expect(movieWithoutPrimaryExtra.primaryExtraActionTitle == nil)
+ }
+
+ @Test func primaryExtraLookupFollowsTheExactServerReturnedMetadataPath() async throws {
+ let capture = RequestCapture()
+ let session = makeMediaExtrasMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, Self.extrasData(ratingKey: "42"))
+ }
+
+ let extra = try await PlexAPIClient(session: session).fetchMediaMetadata(
+ path: "/library/metadata/420?includeFields=thumb%2Ctype",
+ using: try configuration
+ )
+
+ let request = try #require(capture.request)
+ #expect(request.httpMethod == "GET")
+ #expect(request.url?.path == "/library/metadata/420")
+ #expect(request.url?.query == "includeFields=thumb,type")
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Token") == "server-token")
+ #expect(extra.title == "Making the Movie")
+ }
+
+ @Test func documentedExtraSubtypesHaveHumanReadableLabels() throws {
+ let expectedLabels = [
+ "trailer": "Trailer",
+ "deletedScene": "Deleted Scene",
+ "interview": "Interview",
+ "musicVideo": "Music Video",
+ "behindTheScenes": "Behind the Scenes",
+ "sceneOrSample": "Scene or Sample",
+ "liveMusicVideo": "Live Music Video",
+ "lyricMusicVideo": "Lyric Music Video",
+ "concert": "Concert",
+ "featurette": "Featurette",
+ "short": "Short",
+ "other": "Other"
+ ]
+
+ for (subtype, expectedLabel) in expectedLabels {
+ let data = Data(
+ #"{"ratingKey":"\#(subtype)","type":"clip","subtype":"\#(subtype)","title":"Extra"}"#.utf8
+ )
+ let item = try JSONDecoder().decode(PlexMediaItem.self, from: data)
+
+ #expect(item.extraSubtypeLabel == expectedLabel)
+ #expect(item.subtitle == expectedLabel)
+ }
+ }
+
+ @Test func requestUsesDocumentedMetadataExtrasEndpointAndDecodesClipLabels() async throws {
+ let capture = RequestCapture()
+ let session = makeMediaExtrasMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, Self.extrasData(ratingKey: "42"))
+ }
+
+ let extras = try await PlexAPIClient(session: session).fetchMediaExtras(
+ ratingKey: "42",
+ using: try configuration
+ )
+
+ let request = try #require(capture.request)
+ #expect(request.httpMethod == "GET")
+ #expect(request.url?.path == "/library/metadata/42/extras")
+ #expect(request.url?.query == nil)
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Token") == "server-token")
+
+ let extra = try #require(extras.first)
+ #expect(extra.title == "Making the Movie")
+ #expect(extra.subtype == "behindTheScenes")
+ #expect(extra.extraSubtypeLabel == "Behind the Scenes")
+ #expect(extra.subtitle == "Behind the Scenes")
+ #expect(extra.isPlayable)
+ }
+
+ @MainActor
+ @Test func failedRefreshKeepsExistingExtrasVisible() async throws {
+ let scenario = MediaExtrasScenario()
+ let suiteName = "PlexBarTests.failedMediaExtrasRefresh"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ let store = try makeStore(defaults: defaults, scenario: scenario)
+ let item = try decodeItem(ratingKey: "42")
+
+ await store.loadMediaExtras(for: item)
+ #expect(store.mediaExtras(for: item).map(\.title) == ["Making the Movie"])
+ #expect(store.mediaExtrasErrorMessage(for: item) == nil)
+
+ scenario.failRequests(for: "42")
+ await store.loadMediaExtras(for: item, forceRefresh: true)
+
+ #expect(store.mediaExtras(for: item).map(\.title) == ["Making the Movie"])
+ #expect(store.mediaExtrasErrorMessage(for: item) != nil)
+ #expect(store.hasLoadedMediaExtras(for: item))
+ }
+
+ @MainActor
+ @Test func extrasCacheIsLeastRecentlyUsedRouteResolvableAndResettable() async throws {
+ let scenario = MediaExtrasScenario()
+ let suiteName = "PlexBarTests.mediaExtrasCacheIsBounded"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ let store = try makeStore(
+ defaults: defaults,
+ scenario: scenario,
+ mediaExtrasLimit: 2
+ )
+ let first = try decodeItem(ratingKey: "1")
+ let second = try decodeItem(ratingKey: "2")
+ let third = try decodeItem(ratingKey: "3")
+
+ await store.loadMediaExtras(for: first)
+ await store.loadMediaExtras(for: second)
+ await store.loadMediaExtras(for: first)
+ await store.loadMediaExtras(for: third)
+
+ #expect(store.hasLoadedMediaExtras(for: first))
+ #expect(!store.hasLoadedMediaExtras(for: second))
+ #expect(store.hasLoadedMediaExtras(for: third))
+ #expect(store.mediaExtrasState.recency == ["1", "3"])
+
+ let extra = try #require(store.mediaExtras(for: first).first)
+ #expect(store.item(for: PlexMediaRoute(item: extra)) == extra)
+
+ store.resetMediaExtras()
+ #expect(store.mediaExtrasState.recency.isEmpty)
+ #expect(store.mediaExtras(for: first).isEmpty)
+ #expect(store.item(for: PlexMediaRoute(item: extra)) == nil)
+ }
+
+ @MainActor
+ @Test func detailDiscoveryLoadsAndResetsExtrasAndRelatedContentTogether() async throws {
+ let scenario = MediaExtrasScenario()
+ let suiteName = "PlexBarTests.detailDiscoveryLoadsBothContracts"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ let store = try makeStore(defaults: defaults, scenario: scenario)
+ let item = try decodeItem(ratingKey: "42")
+
+ #expect(!store.detailDiscoveryPresentation(for: item).isVisible)
+
+ await store.loadDetailDiscoveryContent(for: item)
+
+ #expect(store.mediaExtras(for: item).map(\.title) == ["Making the Movie"])
+ #expect(store.relatedHubs(for: item).map(\.title) == ["Related Movies"])
+ #expect(store.detailDiscoveryPresentation(for: item) == PlexDetailDiscoveryPresentation(
+ hasContent: true,
+ hasError: false,
+ isLoading: false
+ ))
+
+ store.resetDetailDiscoveryContent()
+
+ #expect(store.mediaExtras(for: item).isEmpty)
+ #expect(store.relatedHubs(for: item).isEmpty)
+ #expect(!store.detailDiscoveryPresentation(for: item).isVisible)
+ }
+
+ @MainActor
+ @Test func staleExtrasResponseCannotRepopulateAResetAndReloadedSource() async throws {
+ let scenario = MediaExtrasScenario()
+ let suiteName = "PlexBarTests.staleMediaExtrasResponse"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ let store = try makeStore(defaults: defaults, scenario: scenario)
+ let item = try decodeItem(ratingKey: "42")
+ let staleExtra = try JSONDecoder().decode(
+ PlexMediaItem.self,
+ from: Data(#"{"ratingKey":"stale-extra","type":"clip","title":"Stale Extra"}"#.utf8)
+ )
+ let gate = MediaExtrasLoadGate()
+
+ let staleLoad = Task {
+ await store.loadMediaExtras(for: item) { ratingKey in
+ #expect(ratingKey == "42")
+ await gate.suspendLoad()
+ return [staleExtra]
+ }
+ }
+ await gate.waitUntilLoadStarts()
+
+ store.resetMediaExtras()
+ await store.loadMediaExtras(for: item)
+ await gate.finishLoad()
+ await staleLoad.value
+
+ #expect(store.mediaExtras(for: item).map(\.title) == ["Making the Movie"])
+ #expect(store.mediaExtrasState.itemsByRatingKey["42"]?.contains(staleExtra) == false)
+ }
+
+ @MainActor
+ @Test func serverConfirmedMutationsUpdateCachedExtras() async throws {
+ let scenario = MediaExtrasScenario()
+ let suiteName = "PlexBarTests.mediaExtrasMutationPropagation"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ let store = try makeStore(defaults: defaults, scenario: scenario)
+ let item = try decodeItem(ratingKey: "42")
+ await store.loadMediaExtras(for: item)
+
+ let refreshed = try JSONDecoder().decode(
+ PlexMediaItem.self,
+ from: Data(#"{"ratingKey":"extra-42","type":"clip","title":"Making the Movie","viewCount":1,"userRating":8}"#.utf8)
+ )
+ store.replaceCachedMediaExtrasWatchedState(with: refreshed)
+ store.replaceCachedMediaExtrasUserRating(with: refreshed)
+
+ let updated = try #require(store.mediaExtras(for: item).first)
+ #expect(updated.isWatched)
+ #expect(updated.userRating == 8)
+ }
+
+ private var configuration: PlexConnectionConfiguration {
+ get throws {
+ PlexConnectionConfiguration(
+ serverURL: try #require(URL(string: "https://plex.local:32400")),
+ token: "server-token",
+ clientContext: PlexClientContext(clientIdentifier: "client-123")
+ )
+ }
+ }
+
+ @MainActor
+ private func makeStore(
+ defaults: UserDefaults,
+ scenario: MediaExtrasScenario,
+ mediaExtrasLimit: Int = 12
+ ) throws -> PlexBrowserStore {
+ let settings = PlexSettingsStore(
+ defaults: defaults,
+ credentialStore: PlexMemoryCredentialStore(
+ credentials: PlexStoredCredentials(
+ userToken: "user-token",
+ serverToken: "server-token"
+ )
+ ),
+ initialCredentials: PlexStoredCredentials(
+ userToken: "user-token",
+ serverToken: "server-token"
+ )
+ )
+ settings.selectedServerIdentifier = "server-id"
+ settings.selectedServerName = "Server"
+ let connectionStore = PlexConnectionStore(settings: settings)
+ connectionStore.activeConnection = PlexResolvedConnection(
+ serverID: "server-id",
+ url: try #require(URL(string: "https://plex.local:32400")),
+ kind: .local,
+ validatedAt: Date()
+ )
+ return PlexBrowserStore(
+ connectionStore: connectionStore,
+ client: PlexAPIClient(session: makeMediaExtrasMockSession { request in
+ try scenario.response(for: request)
+ }),
+ mediaExtrasLimit: mediaExtrasLimit
+ )
+ }
+
+ private func decodeItem(ratingKey: String) throws -> PlexMediaItem {
+ try JSONDecoder().decode(
+ PlexMediaItem.self,
+ from: Data(#"{"ratingKey":"\#(ratingKey)","type":"movie","title":"Item \#(ratingKey)"}"#.utf8)
+ )
+ }
+
+ fileprivate static func extrasData(ratingKey: String) -> Data {
+ Data(#"""
+ {
+ "MediaContainer": {
+ "Metadata": [{
+ "ratingKey": "extra-\#(ratingKey)",
+ "key": "/library/metadata/extra-\#(ratingKey)",
+ "type": "clip",
+ "subtype": "behindTheScenes",
+ "title": "Making the Movie",
+ "duration": 121000,
+ "thumb": "/library/metadata/extra-\#(ratingKey)/thumb",
+ "Media": [{
+ "id": 10,
+ "container": "mp4",
+ "videoCodec": "h264",
+ "Part": [{
+ "id": 20,
+ "key": "/library/parts/20/file.mp4",
+ "container": "mp4"
+ }]
+ }]
+ }]
+ }
+ }
+ """#.utf8)
+ }
+
+ fileprivate static func relatedHubsData(ratingKey: String) -> Data {
+ Data(#"""
+ {
+ "MediaContainer": {
+ "Hub": [{
+ "hubIdentifier": "related.movies.\#(ratingKey)",
+ "title": "Related Movies",
+ "type": "movie",
+ "style": "shelf",
+ "Metadata": [{
+ "ratingKey": "related-\#(ratingKey)",
+ "key": "/library/metadata/related-\#(ratingKey)",
+ "type": "movie",
+ "title": "Related to \#(ratingKey)"
+ }]
+ }]
+ }
+ }
+ """#.utf8)
+ }
+}
+
+private final class MediaExtrasScenario: @unchecked Sendable {
+ private let lock = NSLock()
+ private var failingRatingKeys: Set = []
+
+ func failRequests(for ratingKey: String) {
+ lock.lock()
+ failingRatingKeys.insert(ratingKey)
+ lock.unlock()
+ }
+
+ func response(for request: URLRequest) throws -> (HTTPURLResponse, Data) {
+ let url = try #require(request.url)
+ let ratingKey = url.pathComponents.dropFirst().dropFirst(2).first ?? ""
+
+ lock.lock()
+ let shouldFail = failingRatingKeys.contains(ratingKey)
+ lock.unlock()
+ if shouldFail {
+ throw PlexAPIError.invalidResponse
+ }
+
+ let response = try #require(HTTPURLResponse(
+ url: url,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ let data = if url.path.hasSuffix("/related") {
+ PlexMediaExtrasTests.relatedHubsData(ratingKey: ratingKey)
+ } else {
+ PlexMediaExtrasTests.extrasData(ratingKey: ratingKey)
+ }
+ return (response, data)
+ }
+}
+
+private actor MediaExtrasLoadGate {
+ private var hasStarted = false
+ private var startContinuation: CheckedContinuation?
+ private var loadContinuation: CheckedContinuation?
+
+ func suspendLoad() async {
+ hasStarted = true
+ startContinuation?.resume()
+ startContinuation = nil
+ await withCheckedContinuation { continuation in
+ loadContinuation = continuation
+ }
+ }
+
+ func waitUntilLoadStarts() async {
+ guard !hasStarted else {
+ return
+ }
+ await withCheckedContinuation { continuation in
+ startContinuation = continuation
+ }
+ }
+
+ func finishLoad() {
+ loadContinuation?.resume()
+ loadContinuation = nil
+ }
+}
+
+private func makeMediaExtrasMockSession(
+ handler: @escaping @Sendable (URLRequest) throws -> (HTTPURLResponse, Data)
+) -> URLSession {
+ MediaExtrasMockURLProtocol.requestHandler = handler
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.protocolClasses = [MediaExtrasMockURLProtocol.self]
+ return URLSession(configuration: configuration)
+}
+
+private final class MediaExtrasMockURLProtocol: URLProtocol, @unchecked Sendable {
+ nonisolated(unsafe) static var requestHandler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))?
+
+ override static func canInit(with request: URLRequest) -> Bool {
+ true
+ }
+
+ override static func canonicalRequest(for request: URLRequest) -> URLRequest {
+ request
+ }
+
+ override func startLoading() {
+ guard let handler = Self.requestHandler else {
+ client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
+ return
+ }
+
+ do {
+ let (response, data) = try handler(request)
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: data)
+ client?.urlProtocolDidFinishLoading(self)
+ } catch {
+ client?.urlProtocol(self, didFailWithError: error)
+ }
+ }
+
+ override func stopLoading() {}
+}
diff --git a/PlexBarTests/PlexMediaFactsPresentationTests.swift b/PlexBarTests/PlexMediaFactsPresentationTests.swift
new file mode 100644
index 0000000..7834908
--- /dev/null
+++ b/PlexBarTests/PlexMediaFactsPresentationTests.swift
@@ -0,0 +1,71 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+struct PlexMediaFactsPresentationTests {
+ @Test(arguments: [
+ "TV-Y", "TV-Y7", "TV-Y7-FV", "TV-G", "TV-PG", "TV-14", "TV-MA",
+ "G", "PG", "PG-13", "R", "NC-17", "U", "12A", "15", "18",
+ "FSK 16", "au/MA15+", "TV-MA (L, S, V)", "NR", "Unrated", "Custom classification",
+ ])
+ func preservesContentRatingsSeparatelyFromOtherFacts(rating: String) throws {
+ let item = try makeItem(type: "movie", contentRating: " \(rating)\n", year: 2025)
+
+ #expect(item.factsPresentation.facts == ["2025"])
+ #expect(item.factsPresentation.contentRating == rating)
+ #expect(item.factsLine == "2025 · \(rating)")
+ }
+
+ @Test(arguments: [nil, "", " \n\t "] as [String?])
+ func missingRatingsDoNotBecomeUnratedOrLeaveSeparators(rating: String?) throws {
+ let item = try makeItem(type: "movie", contentRating: rating, year: 2025)
+ let empty = try makeItem(type: "episode", contentRating: rating)
+
+ #expect(item.factsPresentation.contentRating == nil)
+ #expect(item.factsLine == "2025")
+ #expect(empty.factsPresentation.facts.isEmpty)
+ #expect(empty.factsPresentation.contentRating == nil)
+ #expect(empty.factsLine == nil)
+ #expect(PlexMediaSummaryPresentation(item: empty).episodeFacts == nil)
+ }
+
+ @Test func ratingWithoutOtherFactsRemainsVisible() throws {
+ let item = try makeItem(type: "movie", contentRating: "NR")
+
+ #expect(item.factsPresentation.facts.isEmpty)
+ #expect(item.factsPresentation.contentRating == "NR")
+ #expect(item.factsLine == "NR")
+ }
+
+ @Test func episodeFactsUseEpisodeClassificationAndKeepReleaseDateOutsideBadge() throws {
+ let episode = try JSONDecoder().decode(PlexMediaItem.self, from: Data(#"""
+ {
+ "ratingKey": "episode", "type": "episode", "title": "Episode",
+ "duration": 2160000, "originallyAvailableAt": "2025-04-01",
+ "parentIndex": 1, "index": 3, "contentRating": "TV-14"
+ }
+ """#.utf8))
+ let presentation = PlexMediaSummaryPresentation(item: episode)
+ let releaseDate = try #require(PlexMediaMetadataPresentation(item: episode).facts.first {
+ $0.kind == .releaseDate
+ }?.value)
+ let duration = try #require(episode.formattedDuration)
+
+ #expect(presentation.episodeFactsPresentation.facts == [duration, releaseDate])
+ #expect(presentation.episodeFactsPresentation.contentRating == "TV-14")
+ #expect(presentation.episodeFacts == "\(duration) · \(releaseDate) · TV-14")
+
+ let otherEpisode = try makeItem(type: "episode", contentRating: "TV-MA")
+ #expect(PlexMediaSummaryPresentation(item: otherEpisode).episodeFactsPresentation.contentRating == "TV-MA")
+ let missingRatingEpisode = try makeItem(type: "episode", contentRating: nil)
+ #expect(PlexMediaSummaryPresentation(item: missingRatingEpisode).episodeFactsPresentation.contentRating == nil)
+ }
+
+ private func makeItem(type: String, contentRating: String?, year: Int? = nil) throws -> PlexMediaItem {
+ var json: [String: Any] = ["ratingKey": "item", "type": type, "title": "Title"]
+ json["contentRating"] = contentRating
+ json["year"] = year
+ return try JSONDecoder().decode(PlexMediaItem.self, from: JSONSerialization.data(withJSONObject: json))
+ }
+}
diff --git a/PlexBarTests/PlexMediaHierarchyLayoutTests.swift b/PlexBarTests/PlexMediaHierarchyLayoutTests.swift
new file mode 100644
index 0000000..ca8a197
--- /dev/null
+++ b/PlexBarTests/PlexMediaHierarchyLayoutTests.swift
@@ -0,0 +1,232 @@
+import PlexModels
+import AppKit
+import SwiftUI
+import Testing
+@testable import PlexBar
+
+@MainActor
+@Suite
+struct PlexMediaHierarchyLayoutTests {
+ @Test func movieDetailsDoNotReserveSpaceForAbsentHistory() {
+ let headerRecorder = HierarchyLayoutSizeRecorder()
+ let discoveryRecorder = HierarchyLayoutSizeRecorder()
+ let sectionsRecorder = HierarchyLayoutSizeRecorder()
+ let hostingView = NSHostingView(rootView:
+ VStack(alignment: .leading, spacing: 30) {
+ HierarchyLayoutSizeProbe(
+ recorder: headerRecorder,
+ intrinsicHeight: 420
+ )
+ .fixedSize(horizontal: false, vertical: true)
+
+ HierarchyLayoutSizeProbe(
+ recorder: discoveryRecorder,
+ intrinsicHeight: 160
+ )
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .background {
+ HierarchyLayoutSizeProbe(
+ recorder: sectionsRecorder,
+ intrinsicHeight: 0
+ )
+ }
+ .frame(width: 900, height: 900, alignment: .topLeading)
+ )
+ hostingView.frame = CGRect(x: 0, y: 0, width: 900, height: 900)
+
+ hostingView.layoutSubtreeIfNeeded()
+
+ #expect(abs(headerRecorder.size.height - 420) < 0.5)
+ #expect(abs(discoveryRecorder.size.height - 160) < 0.5)
+ #expect(abs(sectionsRecorder.size.height - 610) < 0.5)
+ #expect(abs(headerRecorder.frame.minY - discoveryRecorder.frame.maxY - 30) < 0.5)
+ }
+
+ @Test func realShowOverviewDoesNotConsumeTheViewportBeforeSeasons() throws {
+ let suiteName = "PlexBarTests.realShowOverviewDoesNotConsumeTheViewportBeforeSeasons"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let item = try JSONDecoder().decode(PlexMediaItem.self, from: Data(#"""
+ {
+ "ratingKey": "studio-show",
+ "key": "/library/metadata/studio-show/children",
+ "title": "The Studio (2025)",
+ "type": "show",
+ "year": 2025,
+ "duration": 1800000,
+ "summary": "A legacy movie studio tries to survive in a rapidly changing world.",
+ "studio": "Point Grey Pictures",
+ "contentRating": "TV-MA",
+ "originallyAvailableAt": "2025-03-26",
+ "audienceRating": 7.8,
+ "Genre": [{ "tag": "Comedy" }, { "tag": "Drama" }],
+ "Role": [
+ { "tag": "Seth Rogen", "role": "Matt Remick" },
+ { "tag": "Catherine O'Hara", "role": "Patty Leigh" },
+ { "tag": "Ike Barinholtz", "role": "Sal Saperstein" },
+ { "tag": "Chase Sui Wonders", "role": "Quinn Hackett" },
+ { "tag": "Kathryn Hahn", "role": "Maya Mason" },
+ { "tag": "Keyla Monterroso Mejia", "role": "Petra" },
+ { "tag": "Dewayne Perkins", "role": "Tyler" },
+ { "tag": "Nicholas Stoller", "role": "Nicholas Stoller" },
+ { "tag": "Bryan Cranston", "role": "Griffin Mill" },
+ { "tag": "David Krumholtz", "role": "Mitch Weitz" },
+ { "tag": "Zoë Kravitz", "role": "Zoë Kravitz" },
+ { "tag": "Dave Franco", "role": "Dave Franco" },
+ { "tag": "Matt Belloni", "role": "Matt Belloni" },
+ { "tag": "Lisa Gilroy", "role": "Gabby" },
+ { "tag": "Rhea Perlman", "role": "Matt's Mom (voice)" },
+ { "tag": "Ron Howard", "role": "Ron Howard" },
+ { "tag": "Peter Berg", "role": "Peter Berg" },
+ { "tag": "Steve Buscemi", "role": "Steve Buscemi" }
+ ],
+ "Country": [{ "tag": "United States of America" }]
+ }
+ """#.utf8))
+ let settingsStore = PlexSettingsStore(
+ defaults: defaults,
+ credentialStore: PlexMemoryCredentialStore()
+ )
+ let overviewRecorder = HierarchyLayoutSizeRecorder()
+ let childrenRecorder = HierarchyLayoutSizeRecorder()
+ let metadataRecorder = HierarchyLayoutSizeRecorder()
+
+ let hostingView = NSHostingView(rootView:
+ NavigationSplitView {
+ List {
+ Label("Home", systemImage: "house")
+ }
+ .navigationSplitViewColumnWidth(228)
+ } detail: {
+ NavigationStack {
+ ZStack(alignment: .topLeading) {
+ Color(nsColor: .windowBackgroundColor)
+ .ignoresSafeArea()
+
+ ScrollView {
+ VStack(alignment: .leading, spacing: 30) {
+ PlexMediaOverview(
+ item: item,
+ settingsStore: settingsStore,
+ serverURL: nil,
+ showsPlaybackControl: true,
+ isPlaybackEnabled: true,
+ isPreparingPlayback: false,
+ preparePlayback: {},
+ showsAutomaticDownloadControl: false,
+ prepareAutomaticDownload: {}
+ )
+ .background {
+ HierarchyLayoutSizeProbe(
+ recorder: overviewRecorder,
+ intrinsicHeight: 0
+ )
+ }
+
+ Text("Seasons")
+ .font(.title2.weight(.semibold))
+ .background {
+ HierarchyLayoutSizeProbe(
+ recorder: childrenRecorder,
+ intrinsicHeight: 0
+ )
+ }
+
+ PlexMediaMetadataView(item: item)
+ .frame(maxWidth: 980, alignment: .leading)
+ .background {
+ HierarchyLayoutSizeProbe(
+ recorder: metadataRecorder,
+ intrinsicHeight: 0
+ )
+ }
+ }
+ .frame(maxWidth: 1_100, alignment: .leading)
+ .scenePadding()
+ }
+ }
+ .navigationTitle(item.title)
+ .toolbar {
+ Button("Reload", systemImage: "arrow.clockwise") {}
+ }
+ }
+ }
+ .navigationSplitViewStyle(.balanced)
+ .frame(width: 1_400, height: 900, alignment: .topLeading)
+ )
+ hostingView.frame = CGRect(x: 0, y: 0, width: 1_400, height: 900)
+
+ hostingView.layoutSubtreeIfNeeded()
+
+ #expect(overviewRecorder.size.height >= PlexCinematicHeroMetrics.minimumHeight)
+ #expect(overviewRecorder.size.height <= PlexCinematicHeroMetrics.maximumHeight)
+ #expect(childrenRecorder.size.height > 15)
+ #expect(childrenRecorder.size.height < 60)
+ #expect(abs(overviewRecorder.frame.minY - childrenRecorder.frame.maxY - 30) < 0.5)
+ #expect(abs(childrenRecorder.frame.minY - metadataRecorder.frame.maxY - 30) < 0.5)
+ }
+}
+
+@MainActor
+private final class HierarchyLayoutSizeRecorder {
+ var size = CGSize.zero
+ var frame = CGRect.zero
+}
+
+private struct HierarchyLayoutSizeProbe: NSViewRepresentable {
+ let recorder: HierarchyLayoutSizeRecorder
+ let intrinsicHeight: CGFloat
+
+ func makeNSView(context: Context) -> NSView {
+ HierarchyLayoutProbeNSView(
+ recorder: recorder,
+ intrinsicHeight: intrinsicHeight
+ )
+ }
+
+ func updateNSView(_ nsView: NSView, context: Context) {}
+}
+
+@MainActor
+private final class HierarchyLayoutProbeNSView: NSView {
+ private let recorder: HierarchyLayoutSizeRecorder
+ private let intrinsicHeight: CGFloat
+
+ init(recorder: HierarchyLayoutSizeRecorder, intrinsicHeight: CGFloat) {
+ self.recorder = recorder
+ self.intrinsicHeight = intrinsicHeight
+ super.init(frame: .zero)
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ nil
+ }
+
+ override var intrinsicContentSize: NSSize {
+ NSSize(width: 100, height: intrinsicHeight)
+ }
+
+ override func setFrameSize(_ newSize: NSSize) {
+ super.setFrameSize(newSize)
+ recorder.size = newSize
+ recordFrame()
+ }
+
+ override func setFrameOrigin(_ newOrigin: NSPoint) {
+ super.setFrameOrigin(newOrigin)
+ recordFrame()
+ }
+
+ override func layout() {
+ super.layout()
+ recordFrame()
+ }
+
+ private func recordFrame() {
+ recorder.frame = convert(bounds, to: nil)
+ }
+}
diff --git a/PlexBarTests/PlexMediaHierarchyNavigationTests.swift b/PlexBarTests/PlexMediaHierarchyNavigationTests.swift
new file mode 100644
index 0000000..220bb23
--- /dev/null
+++ b/PlexBarTests/PlexMediaHierarchyNavigationTests.swift
@@ -0,0 +1,118 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+@Suite
+struct PlexMediaHierarchyNavigationTests {
+ @Test func episodeUsesExactShowAndSeasonIdentifiers() throws {
+ let episode = try decodeItem(#"""
+ {
+ "ratingKey": "102",
+ "title": "Episode",
+ "type": "episode",
+ "parentRatingKey": "101",
+ "parentTitle": "Season 2",
+ "grandparentRatingKey": "100",
+ "grandparentTitle": "The Show"
+ }
+ """#)
+
+ #expect(episode.hierarchyDestinations.map(\.relationship) == [.show, .season])
+ #expect(episode.hierarchyDestinations.map(\.title) == ["The Show", "Season 2"])
+ #expect(episode.hierarchyDestinations.map(\.route.ratingKey) == ["100", "101"])
+ }
+
+ @Test func trackUsesExactArtistAndAlbumIdentifiers() throws {
+ let track = try decodeItem(#"""
+ {
+ "ratingKey": "202",
+ "title": "Track",
+ "type": "track",
+ "parentRatingKey": "201",
+ "parentTitle": "The Album",
+ "grandparentRatingKey": "200",
+ "grandparentTitle": "The Artist"
+ }
+ """#)
+
+ #expect(track.hierarchyDestinations.map(\.relationship) == [.artist, .album])
+ #expect(track.hierarchyDestinations.map(\.title) == ["The Artist", "The Album"])
+ #expect(track.hierarchyDestinations.map(\.route.ratingKey) == ["200", "201"])
+ }
+
+ @Test func episodeSkipsSeasonNavigationWhenPlexAdvertisesSkipParent() throws {
+ let episode = try decodeItem(#"""
+ {
+ "ratingKey": "102",
+ "title": "Episode",
+ "type": "episode",
+ "parentRatingKey": "101",
+ "parentTitle": "Season 1",
+ "grandparentRatingKey": "100",
+ "grandparentTitle": "The Show",
+ "skipParent": "1"
+ }
+ """#)
+
+ #expect(episode.skipParent == true)
+ #expect(episode.hierarchyDestinations.map(\.relationship) == [.show])
+ #expect(episode.hierarchyDestinations.map(\.title) == ["The Show"])
+ #expect(episode.hierarchyDestinations.map(\.route.ratingKey) == ["100"])
+ }
+
+ @Test func skipParentDoesNotInventAReplacementHierarchyDestination() throws {
+ let episode = try decodeItem(#"""
+ {
+ "ratingKey": "102",
+ "title": "Episode",
+ "type": "episode",
+ "parentRatingKey": "101",
+ "parentTitle": "Season 1",
+ "skipParent": true
+ }
+ """#)
+
+ #expect(episode.skipParent == true)
+ #expect(episode.hierarchyDestinations.isEmpty)
+ }
+
+ @Test func hierarchyNavigationDoesNotGuessMissingOrSelfReferentialRoutes() throws {
+ let episode = try decodeItem(#"""
+ {
+ "ratingKey": "102",
+ "title": "Episode",
+ "type": "episode",
+ "parentRatingKey": "102",
+ "parentTitle": "Self",
+ "grandparentTitle": "Missing ID"
+ }
+ """#)
+
+ #expect(episode.hierarchyDestinations.isEmpty)
+ }
+
+ @Test func metadataRefreshRetainsTheExactBrowseHierarchyPath() throws {
+ let browseItem = try decodeItem(#"{"ratingKey":"100","key":"/library/metadata/100/children?includeGuids=1","type":"show","title":"Show"}"#)
+ let details = try decodeItem(#"{"ratingKey":"100","type":"show","title":"Show","summary":"Details"}"#)
+
+ let requestItem = browseItem.hierarchyRequestItem(afterRefreshingWith: details)
+
+ #expect(requestItem == browseItem)
+ #expect(requestItem.childrenPath == "/library/metadata/100/children?includeGuids=1")
+ }
+
+ @Test func metadataRefreshSuppliesHierarchyPathWhenTheOriginalRouteHasNone() throws {
+ let unresolvedItem = try decodeItem(#"{"ratingKey":"100","type":"show","title":"Show"}"#)
+ let details = try decodeItem(#"{"ratingKey":"100","key":"/library/metadata/100/children","type":"show","title":"Show"}"#)
+
+ let requestItem = unresolvedItem.hierarchyRequestItem(afterRefreshingWith: details)
+
+ #expect(requestItem == details)
+ #expect(requestItem.childrenPath == "/library/metadata/100/children")
+ }
+
+ private func decodeItem(_ json: String) throws -> PlexMediaItem {
+ try JSONDecoder().decode(PlexMediaItem.self, from: Data(json.utf8))
+ }
+}
diff --git a/PlexBarTests/PlexMediaMetadataPresentationTests.swift b/PlexBarTests/PlexMediaMetadataPresentationTests.swift
new file mode 100644
index 0000000..c974610
--- /dev/null
+++ b/PlexBarTests/PlexMediaMetadataPresentationTests.swift
@@ -0,0 +1,254 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+struct PlexMediaMetadataPresentationTests {
+ @Test func decodesAndLabelsPublishedPlexMetadataFields() throws {
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "42",
+ "title": "Charade",
+ "originalTitle": "The Unsuspected Wife",
+ "type": "movie",
+ "studio": "Universal Pictures",
+ "originallyAvailableAt": "1963-12-05",
+ "rating": 7.9,
+ "ratingImage": "imdb://image.rating",
+ "audienceRating": 8.2,
+ "audienceRatingImage": "rottentomatoes://image.rating.upright",
+ "Guid": [
+ { "id": "imdb://tt0047437" },
+ { "id": "tmdb://4808" }
+ ],
+ "Rating": [
+ { "image": "imdb://image.rating", "type": "audience", "value": 7.9 },
+ { "image": "rottentomatoes://image.rating.ripe", "type": "critic", "value": 8.6 },
+ { "image": "rottentomatoes://image.rating.upright", "type": "audience", "value": 8.2 },
+ { "image": "themoviedb://image.rating", "type": "audience", "value": 7.5 }
+ ],
+ "Genre": [
+ { "tag": "Comedy" },
+ { "tag": "Mystery" },
+ { "tag": "Comedy" }
+ ],
+ "Director": [{ "tag": "Stanley Donen" }],
+ "Writer": [
+ { "tag": "Peter Stone" },
+ { "tag": "Marc Behm" }
+ ],
+ "Role": [
+ { "tag": "Cary Grant", "role": "Peter Joshua" },
+ { "tag": "Audrey Hepburn", "role": "Regina Lampert" }
+ ],
+ "Country": [{ "tag": "United States" }]
+ }
+ """#)
+
+ #expect(item.studio == "Universal Pictures")
+ #expect(item.originallyAvailableAt == "1963-12-05")
+ #expect(item.ratingImage == "imdb://image.rating")
+ #expect(item.audienceRatingImage == "rottentomatoes://image.rating.upright")
+ #expect(item.guids.map(\.id) == ["imdb://tt0047437", "tmdb://4808"])
+ #expect(item.ratings.count == 4)
+ #expect(item.ratings[1].value == 8.6)
+ #expect(item.writers.map(\.tag) == ["Peter Stone", "Marc Behm"])
+ #expect(item.countries.map(\.tag) == ["United States"])
+ #expect(item.roles.map(\.role) == ["Peter Joshua", "Regina Lampert"])
+
+ let presentation = PlexMediaMetadataPresentation(
+ item: item,
+ locale: Locale(identifier: "en_US")
+ )
+
+ #expect(presentation.facts == [
+ .init(kind: .originalTitle, label: "Original Title", value: "The Unsuspected Wife"),
+ .init(kind: .studio, label: "Studio", value: "Universal Pictures"),
+ .init(kind: .releaseDate, label: "Released", value: "December 5, 1963"),
+ .init(kind: .genres, label: "Genres", value: "Comedy, Mystery"),
+ .init(kind: .countries, label: "Country", value: "United States"),
+ ])
+
+ #expect(PlexExternalRatingsPresentation(
+ item: item,
+ locale: Locale(identifier: "en_US")
+ ).ratings == [
+ .init(
+ source: .imdb,
+ displayValue: "7.9",
+ accessibilityLabel: "IMDb rating, 7.9 out of 10",
+ isFresh: nil,
+ destinationURL: URL(string: "https://www.imdb.com/title/tt0047437/")
+ ),
+ .init(
+ source: .rottenTomatoes,
+ displayValue: "86%",
+ accessibilityLabel: "Rotten Tomatoes Tomatometer, 86%",
+ isFresh: true,
+ destinationURL: nil
+ ),
+ ])
+ }
+
+ @Test func externalRatingsIgnoreAudienceTomatoesTMDBAndGenericRatings() throws {
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "ratings-without-approved-sources",
+ "title": "Movie",
+ "type": "movie",
+ "rating": 7.4,
+ "ratingImage": "themoviedb://image.rating",
+ "audienceRating": 8,
+ "audienceRatingImage": "rottentomatoes://image.rating.upright",
+ "Rating": [
+ { "image": "themoviedb://image.rating", "type": "audience", "value": 7.4 },
+ { "image": "rottentomatoes://image.rating.upright", "type": "audience", "value": 8 }
+ ]
+ }
+ """#)
+
+ #expect(PlexExternalRatingsPresentation(item: item).ratings.isEmpty)
+ #expect(PlexMediaMetadataPresentation(item: item).facts.isEmpty)
+ }
+
+ @Test func imdbRatingsAlwaysShowOneDecimalPlace() throws {
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "whole-number-imdb-rating",
+ "title": "Episode",
+ "type": "episode",
+ "Rating": [
+ { "image": "imdb://image.rating", "type": "audience", "value": 8 }
+ ]
+ }
+ """#)
+
+ let rating = try #require(PlexExternalRatingsPresentation(
+ item: item,
+ locale: Locale(identifier: "en_US")
+ ).ratings.first)
+
+ #expect(rating.displayValue == "8.0")
+ #expect(rating.accessibilityLabel == "IMDb rating, 8.0 out of 10")
+ }
+
+ @Test func externalRatingsOmitInvalidScoresAndIdentifyRottenTomatometer() throws {
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "mixed-rating-validity",
+ "title": "Movie",
+ "type": "movie",
+ "Rating": [
+ { "image": "imdb://image.rating", "type": "audience", "value": 11 },
+ { "image": "rottentomatoes://image.rating.ripe", "type": "critic", "value": "5.9" }
+ ]
+ }
+ """#)
+
+ #expect(PlexExternalRatingsPresentation(
+ item: item,
+ locale: Locale(identifier: "en_US")
+ ).ratings == [
+ .init(
+ source: .rottenTomatoes,
+ displayValue: "59%",
+ accessibilityLabel: "Rotten Tomatoes Tomatometer, 59%",
+ isFresh: false,
+ destinationURL: nil
+ ),
+ ])
+ }
+
+ @Test func imdbRatingRejectsMalformedAndNonTitleExternalIdentifiers() throws {
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "invalid-imdb-guids",
+ "title": "Movie",
+ "type": "movie",
+ "Guid": [
+ { "id": "imdb://nm0000158" },
+ { "id": "imdb://tt-not-a-number" },
+ { "id": "https://www.imdb.com/title/tt0047437/" }
+ ],
+ "Rating": [
+ { "image": "imdb://image.rating", "type": "audience", "value": 7.9 }
+ ]
+ }
+ """#)
+
+ let rating = try #require(PlexExternalRatingsPresentation(item: item).ratings.first)
+ #expect(rating.source == .imdb)
+ #expect(rating.destinationURL == nil)
+ }
+
+ @Test func usesLabelTerminologyAndPreservesPublishedTimestampPrecision() throws {
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "album-1",
+ "title": "Album",
+ "originalTitle": "album",
+ "type": "album",
+ "studio": "Record Label",
+ "originallyAvailableAt": "2026-08-30 10:15:42",
+ "Country": [
+ { "tag": "United States" },
+ { "tag": "Canada" }
+ ]
+ }
+ """#)
+
+ let presentation = PlexMediaMetadataPresentation(
+ item: item,
+ locale: Locale(identifier: "en_US")
+ )
+
+ #expect(presentation.facts.map(\.kind) == [.studio, .releaseDate, .countries])
+ #expect(presentation.facts[0].label == "Label")
+ #expect(presentation.facts[0].value == "Record Label")
+ #expect(presentation.facts[1].label == "Released")
+ #expect(presentation.facts[1].value.contains("August 30, 2026"))
+ #expect(presentation.facts[1].value.contains("10:15:42"))
+ #expect(presentation.facts[2].label == "Countries")
+ #expect(presentation.facts[2].value == "United States, Canada")
+ }
+
+ @Test func omitsMalformedDatesOutOfRangeRatingsAndBlankTags() throws {
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "bad-contract-values",
+ "title": "Movie",
+ "type": "movie",
+ "originallyAvailableAt": "2026-02-30",
+ "rating": 11,
+ "audienceRating": -1,
+ "Genre": [{ "tag": " " }],
+ "Role": []
+ }
+ """#)
+
+ #expect(PlexMediaMetadataPresentation(item: item).facts.isEmpty)
+ }
+
+ @Test func photoDetailsIncludeExactServerDimensions() throws {
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "photo-1",
+ "title": "Vacation",
+ "type": "photo",
+ "Media": [{
+ "width": "6000",
+ "height": "4000",
+ "Part": [{ "key": "/library/parts/700/file.jpeg" }]
+ }]
+ }
+ """#)
+
+ #expect(PlexMediaMetadataPresentation(item: item).facts == [
+ .init(kind: .dimensions, label: "Dimensions", value: "6,000 × 4,000"),
+ ])
+ }
+
+ private func decodeItem(_ json: String) throws -> PlexMediaItem {
+ try JSONDecoder().decode(PlexMediaItem.self, from: Data(json.utf8))
+ }
+}
diff --git a/PlexBarTests/PlexMediaProviderTests.swift b/PlexBarTests/PlexMediaProviderTests.swift
new file mode 100644
index 0000000..bb24eb1
--- /dev/null
+++ b/PlexBarTests/PlexMediaProviderTests.swift
@@ -0,0 +1,793 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+@Suite(.serialized)
+struct PlexMediaProviderTests {
+ @Test func decodesLibraryTimelineEndpointsFromAdvertisedProviderFeatures() throws {
+ let envelope = try JSONDecoder().decode(
+ PlexMediaProvidersEnvelope.self,
+ from: mediaProvidersData()
+ )
+
+ #expect(try envelope.mediaContainer.libraryProviderEndpoints() == providerEndpoints)
+ #expect(envelope.mediaContainer.allowSync == true)
+ }
+
+ @Test func decodesPlayQueueIndependentlyFromTimelineMutationPaths() throws {
+ let data = Data(#"{"MediaContainer":{"MediaProvider":[{"identifier":"com.plexapp.plugins.library","Feature":[{"type":"timeline","key":"/timeline-only"},{"type":"playqueue","key":"/queue-only"}]}]}}"#.utf8)
+ let envelope = try JSONDecoder().decode(PlexMediaProvidersEnvelope.self, from: data)
+ let endpoints = try envelope.mediaContainer.libraryProviderEndpoints()
+
+ #expect(endpoints.timelinePath == "/timeline-only")
+ #expect(endpoints.scrobblePath == nil)
+ #expect(endpoints.unscrobblePath == nil)
+ #expect(endpoints.playQueuePath == "/queue-only")
+ #expect(endpoints.supportsTimeline)
+ #expect(!endpoints.supportsWatchedStateMutation)
+ #expect(endpoints.supportsPlayQueues)
+ }
+
+ @Test func decodesAdvertisedPlaylistEndpointAndProviderWriteAccess() throws {
+ let writableData = Data(#"{"MediaContainer":{"MediaProvider":[{"identifier":"com.plexapp.plugins.library","Feature":[{"type":"playlist","key":"/provider/playlists?source=library","readOnly":false}]}]}}"#.utf8)
+ let readOnlyData = Data(#"{"MediaContainer":{"MediaProvider":[{"identifier":"com.plexapp.plugins.library","Feature":[{"type":"playlist","key":"/shared/playlists","readonly":true}]}]}}"#.utf8)
+
+ let writable = try JSONDecoder()
+ .decode(PlexMediaProvidersEnvelope.self, from: writableData)
+ .mediaContainer
+ .libraryProviderEndpoints()
+ let readOnly = try JSONDecoder()
+ .decode(PlexMediaProvidersEnvelope.self, from: readOnlyData)
+ .mediaContainer
+ .libraryProviderEndpoints()
+
+ #expect(writable.playlistPath == "/provider/playlists?source=library")
+ #expect(writable.supportsPlaylists)
+ #expect(writable.supportsPlaylistManagement)
+ #expect(readOnly.playlistPath == "/shared/playlists")
+ #expect(readOnly.supportsPlaylists)
+ #expect(!readOnly.supportsPlaylistManagement)
+ }
+
+ @Test func collectionManagementRequiresEveryAdvertisedProviderCapability() throws {
+ let completeData = Data(#"{"MediaContainer":{"MediaProvider":[{"identifier":"com.plexapp.plugins.library","Feature":[{"type":"collection","key":"/provider/collections?source=library"},{"type":"metadata","key":"/provider/metadata?source=library"},{"type":"manage"}]}]}}"#.utf8)
+ let missingCollectionData = Data(#"{"MediaContainer":{"MediaProvider":[{"identifier":"com.plexapp.plugins.library","Feature":[{"type":"metadata","key":"/provider/metadata"},{"type":"manage"}]}]}}"#.utf8)
+
+ let complete = try JSONDecoder()
+ .decode(PlexMediaProvidersEnvelope.self, from: completeData)
+ .mediaContainer
+ .libraryProviderEndpoints()
+ let missingCollection = try JSONDecoder()
+ .decode(PlexMediaProvidersEnvelope.self, from: missingCollectionData)
+ .mediaContainer
+ .libraryProviderEndpoints()
+
+ #expect(complete.collectionPath == "/provider/collections?source=library")
+ #expect(complete.supportsCollectionManagement)
+ #expect(!missingCollection.supportsCollectionManagement)
+ }
+
+ @Test func rejectsProviderResponseWithoutTheLibraryProvider() throws {
+ let data = Data(#"{"MediaContainer":{"MediaProvider":[{"identifier":"another.provider","Feature":[]}]}}"#.utf8)
+ let envelope = try JSONDecoder().decode(PlexMediaProvidersEnvelope.self, from: data)
+
+ #expect(throws: PlexAPIError.self) {
+ try envelope.mediaContainer.libraryProviderEndpoints()
+ }
+ }
+
+ @MainActor
+ @Test func watchedMutationUsesDiscoveredEndpointsRefreshesCachesAndCachesProviderContract() async throws {
+ let suiteName = "PlexBarTests.watchedMutationUsesDiscoveredEndpoints"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let scenario = WatchedMutationScenario()
+ let session = makeMediaProviderMockSession { request in
+ try scenario.response(for: request)
+ }
+ let settings = PlexSettingsStore(
+ defaults: defaults,
+ credentialStore: PlexMemoryCredentialStore(
+ credentials: PlexStoredCredentials(userToken: "user-token", serverToken: "server-token")
+ ),
+ initialCredentials: PlexStoredCredentials(userToken: "user-token", serverToken: "server-token")
+ )
+ settings.selectedServerIdentifier = "server-id"
+ settings.selectedServerName = "Server"
+ let connectionStore = PlexConnectionStore(settings: settings)
+ connectionStore.activeConnection = PlexResolvedConnection(
+ serverID: "server-id",
+ url: try #require(URL(string: "https://plex.local:32400")),
+ kind: .local,
+ validatedAt: Date()
+ )
+ let store = PlexBrowserStore(
+ connectionStore: connectionStore,
+ client: PlexAPIClient(session: session)
+ )
+
+ await store.loadHomeHubs()
+ let initialItem = try #require(store.homeHubs.first?.metadata.first)
+ #expect(!initialItem.isWatched)
+
+ let watchedItem = try await store.setWatched(true, for: initialItem)
+ #expect(watchedItem.isWatched)
+ #expect(store.homeHubs.first?.metadata.first?.isWatched == true)
+
+ let unwatchedItem = try await store.setWatched(false, for: watchedItem)
+ #expect(!unwatchedItem.isWatched)
+ #expect(store.homeHubs.first?.metadata.first?.isWatched == false)
+ #expect(!store.isUpdatingWatchedState(for: unwatchedItem))
+ #expect(scenario.requestCount(for: "/media/providers") == 1)
+ #expect(scenario.requestCount(for: "/provider/played") == 1)
+ #expect(scenario.requestCount(for: "/provider/unplayed") == 1)
+ }
+
+ @MainActor
+ @Test func downloadAuthorizationIsScopedToThePresentedServerConnection() async throws {
+ let suiteName = "PlexBarTests.downloadAuthorizationConnectionScope"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let scenario = WatchedMutationScenario()
+ let settings = PlexSettingsStore(
+ defaults: defaults,
+ credentialStore: PlexMemoryCredentialStore(
+ credentials: PlexStoredCredentials(
+ userToken: "user-token",
+ serverToken: "server-token"
+ )
+ ),
+ initialCredentials: PlexStoredCredentials(
+ userToken: "user-token",
+ serverToken: "server-token"
+ )
+ )
+ settings.selectedServerIdentifier = "server-id"
+ settings.selectedServerName = "Server"
+ let connectionStore = PlexConnectionStore(settings: settings)
+ connectionStore.activeConnection = PlexResolvedConnection(
+ serverID: "server-id",
+ url: try #require(URL(string: "https://plex.local:32400")),
+ kind: .local,
+ validatedAt: Date()
+ )
+ let store = PlexBrowserStore(
+ connectionStore: connectionStore,
+ client: PlexAPIClient(session: makeMediaProviderMockSession { request in
+ try scenario.response(for: request)
+ })
+ )
+ let user = PlexAuthenticatedUser(
+ id: 42,
+ username: "test-user",
+ title: nil,
+ email: nil,
+ thumb: nil,
+ friendlyName: nil,
+ subscriptions: [PlexUserSubscription(
+ type: "plexpass",
+ state: "active",
+ mode: "recurring",
+ active: true,
+ subscribedAt: nil
+ )]
+ )
+
+ let library = mediaProviderDownloadLibrary()
+ #expect(store.downloadAuthorization(for: user, library: library) == nil)
+ await store.loadLibraryProviderCapabilities()
+ #expect(store.downloadAuthorization(for: user, library: library)?.isAuthorized == true)
+
+ connectionStore.activeConnection = PlexResolvedConnection(
+ serverID: "different-server",
+ url: try #require(URL(string: "https://other-plex.local:32400")),
+ kind: .remote,
+ validatedAt: Date()
+ )
+ #expect(store.downloadAuthorization(for: user, library: library) == nil)
+ }
+
+ @MainActor
+ @Test func providerCapabilitiesAreScopedToTheExactPMSTokenWithoutCachingTheSecret() async throws {
+ let suiteName = "PlexBarTests.providerCapabilitiesCredentialScope"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let firstToken = "first-server-token"
+ let secondToken = "second-server-token"
+ let scenario = CredentialScopedProviderScenario(
+ writableToken: firstToken,
+ writableProviderData: Self.mediaProvidersData()
+ )
+ let settings = PlexSettingsStore(
+ defaults: defaults,
+ credentialStore: PlexMemoryCredentialStore(
+ credentials: PlexStoredCredentials(
+ userToken: "user-token",
+ serverToken: firstToken
+ )
+ ),
+ initialCredentials: PlexStoredCredentials(
+ userToken: "user-token",
+ serverToken: firstToken
+ )
+ )
+ settings.selectedServerIdentifier = "server-id"
+ settings.selectedServerName = "Server"
+ let connectionStore = PlexConnectionStore(settings: settings)
+ let serverURL = try #require(URL(string: "https://plex.local:32400"))
+ connectionStore.activeConnection = PlexResolvedConnection(
+ serverID: "server-id",
+ url: serverURL,
+ kind: .local,
+ validatedAt: Date()
+ )
+ let store = PlexBrowserStore(
+ connectionStore: connectionStore,
+ client: PlexAPIClient(session: makeMediaProviderMockSession { request in
+ try scenario.response(for: request)
+ })
+ )
+
+ await store.loadLibraryProviderCapabilities()
+ #expect(store.supportsCollectionManagement)
+
+ settings.serverToken = secondToken
+ await store.loadLibraryProviderCapabilities()
+ #expect(!store.supportsCollectionManagement)
+
+ settings.serverToken = firstToken
+ await store.loadLibraryProviderCapabilities()
+ #expect(store.supportsCollectionManagement)
+ #expect(scenario.providerRequestCount == 2)
+
+ let firstScope = PlexConnectionConfiguration.authenticationCacheScope(for: firstToken)
+ let secondScope = PlexConnectionConfiguration.authenticationCacheScope(for: secondToken)
+ #expect(firstScope != secondScope)
+ #expect(!firstScope.contains(firstToken))
+ #expect(!secondScope.contains(secondToken))
+ }
+
+ @MainActor
+ @Test func personalRatingAndMetadataRefreshUseAdvertisedCapabilities() async throws {
+ let suiteName = "PlexBarTests.personalRatingAndMetadataRefresh"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let scenario = WatchedMutationScenario()
+ let session = makeMediaProviderMockSession { request in
+ try scenario.response(for: request)
+ }
+ let settings = PlexSettingsStore(
+ defaults: defaults,
+ credentialStore: PlexMemoryCredentialStore(
+ credentials: PlexStoredCredentials(userToken: "user-token", serverToken: "server-token")
+ ),
+ initialCredentials: PlexStoredCredentials(userToken: "user-token", serverToken: "server-token")
+ )
+ settings.selectedServerIdentifier = "server-id"
+ settings.selectedServerName = "Server"
+ let connectionStore = PlexConnectionStore(settings: settings)
+ connectionStore.activeConnection = PlexResolvedConnection(
+ serverID: "server-id",
+ url: try #require(URL(string: "https://plex.local:32400")),
+ kind: .local,
+ validatedAt: Date()
+ )
+ let store = PlexBrowserStore(
+ connectionStore: connectionStore,
+ client: PlexAPIClient(session: session)
+ )
+
+ await store.loadHomeHubs()
+ await store.loadLibraryProviderCapabilities()
+ let initialItem = try #require(store.homeHubs.first?.metadata.first)
+ #expect(store.supportsPersonalRatings)
+ #expect(store.supportsMetadataRefresh(for: initialItem))
+
+ let ratedItem = try await store.setPersonalRating(8, for: initialItem)
+ #expect(ratedItem.userRating == 8)
+ #expect(store.homeHubs.first?.metadata.first?.userRating == 8)
+
+ let clearedItem = try await store.setPersonalRating(nil, for: ratedItem)
+ #expect(clearedItem.userRating == nil)
+ #expect(store.homeHubs.first?.metadata.first?.userRating == nil)
+
+ try await store.refreshMetadata(for: clearedItem)
+ #expect(!store.isRefreshingMetadata(for: clearedItem))
+ #expect(scenario.requestCount(for: "/media/providers") == 1)
+ #expect(scenario.requestCount(for: "/provider/rate") == 2)
+ #expect(scenario.requestCount(for: "/provider/metadata/42/refresh") == 1)
+ }
+
+ @MainActor
+ @Test func continueWatchingRemovalUsesAdvertisedActionAndUpdatesOnlyThatHub() async throws {
+ let suiteName = "PlexBarTests.continueWatchingRemoval"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let scenario = WatchedMutationScenario()
+ let session = makeMediaProviderMockSession { request in
+ try scenario.response(for: request)
+ }
+ let settings = PlexSettingsStore(
+ defaults: defaults,
+ credentialStore: PlexMemoryCredentialStore(
+ credentials: PlexStoredCredentials(userToken: "user-token", serverToken: "server-token")
+ ),
+ initialCredentials: PlexStoredCredentials(userToken: "user-token", serverToken: "server-token")
+ )
+ settings.selectedServerIdentifier = "server-id"
+ settings.selectedServerName = "Server"
+ let connectionStore = PlexConnectionStore(settings: settings)
+ connectionStore.activeConnection = PlexResolvedConnection(
+ serverID: "server-id",
+ url: try #require(URL(string: "https://plex.local:32400")),
+ kind: .local,
+ validatedAt: Date()
+ )
+ let store = PlexBrowserStore(
+ connectionStore: connectionStore,
+ client: PlexAPIClient(session: session)
+ )
+
+ await store.loadHomeHubs()
+ await store.loadLibraryProviderCapabilities()
+ let continueWatchingHub = try #require(
+ store.homeState.hubs.first(where: { $0.isContinueWatching })
+ )
+ let initialItem = try #require(continueWatchingHub.metadata.first)
+ await store.loadHomeHubItems(in: continueWatchingHub)
+
+ #expect(store.supportsRemoveFromContinueWatching)
+ #expect(store.homeHubItems(in: continueWatchingHub).map(\.ratingKey) == ["42", "43"])
+
+ try await store.removeFromContinueWatching(initialItem)
+
+ let updatedContinueWatchingHub = try #require(
+ store.homeState.hubs.first(where: { $0.isContinueWatching })
+ )
+ let recentlyAddedHub = try #require(
+ store.homeState.hubs.first(where: { $0.hubIdentifier == "movie.recentlyadded.1" })
+ )
+ #expect(updatedContinueWatchingHub.metadata.allSatisfy { $0.ratingKey != "42" })
+ #expect(store.homeHubItems(in: updatedContinueWatchingHub).map(\.ratingKey) == ["43"])
+ #expect(recentlyAddedHub.metadata.contains(where: { $0.ratingKey == "42" }))
+ #expect(!store.isRemovingFromContinueWatching(initialItem))
+ #expect(scenario.requestCount(for: "/provider/remove-from-continue") == 1)
+ }
+
+ @MainActor
+ @Test func continuousQueueUsesTheProviderAdvertisedPlayQueueEndpoint() async throws {
+ let suiteName = "PlexBarTests.continuousQueueUsesAdvertisedEndpoint"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let scenario = WatchedMutationScenario(providerData: Self.playQueueOnlyMediaProvidersData())
+ let session = makeMediaProviderMockSession { request in
+ try scenario.response(for: request)
+ }
+ let settings = PlexSettingsStore(
+ defaults: defaults,
+ credentialStore: PlexMemoryCredentialStore(
+ credentials: PlexStoredCredentials(userToken: "user-token", serverToken: "server-token")
+ ),
+ initialCredentials: PlexStoredCredentials(userToken: "user-token", serverToken: "server-token")
+ )
+ settings.selectedServerIdentifier = "server-id"
+ settings.selectedServerName = "Server"
+ let connectionStore = PlexConnectionStore(settings: settings)
+ connectionStore.activeConnection = PlexResolvedConnection(
+ serverID: "server-id",
+ url: try #require(URL(string: "https://plex.local:32400")),
+ kind: .local,
+ validatedAt: Date()
+ )
+ let store = PlexBrowserStore(
+ connectionStore: connectionStore,
+ client: PlexAPIClient(session: session)
+ )
+ let item = try decodeItem(#"{"ratingKey":"82","key":"/library/metadata/82","type":"track","title":"Chapter 2"}"#)
+
+ let queue = try await store.continuousPlayQueue(for: item)
+ let page = try await store.refreshPlayQueueWindow(
+ queueID: queue.id,
+ centeredOn: try #require(queue.currentItem.playQueueItemID)
+ )
+
+ #expect(queue.currentItem.ratingKey == "82")
+ #expect(page.id == 92)
+ #expect(page.selectedItemID == "602")
+ #expect(scenario.requestCount(for: "/media/providers") == 1)
+ #expect(scenario.requestCount(for: "/provider/play-queue") == 1)
+ #expect(scenario.requestCount(for: "/provider/play-queue/92") == 1)
+ #expect(!store.supportsWatchedStateMutation(for: item))
+
+ try await store.markPlayedIfSupported(ratingKey: item.ratingKey)
+
+ #expect(scenario.requestCount(for: "/media/providers") == 1)
+ #expect(scenario.requestCount(for: "/provider/played") == 0)
+ }
+
+ @Test func watchedStateMergePreservesContextSpecificListIdentity() throws {
+ let playlistItem = try decodeItem(#"{"ratingKey":"42","playlistItemID":"900","type":"movie","title":"Movie","viewCount":0}"#)
+ let refreshedItem = try decodeItem(#"{"ratingKey":"42","type":"movie","title":"Movie","viewCount":1}"#)
+
+ let merged = playlistItem.mergingWatchedState(from: refreshedItem)
+
+ #expect(merged.id == "playlist-item:900")
+ #expect(merged.isWatched)
+ }
+
+ @Test func personalRatingMergePreservesContextSpecificListIdentity() throws {
+ let playlistItem = try decodeItem(#"{"ratingKey":"42","playlistItemID":"900","type":"movie","title":"Movie","userRating":3}"#)
+ let refreshedItem = try decodeItem(#"{"ratingKey":"42","type":"movie","title":"Movie","userRating":9}"#)
+
+ let merged = playlistItem.mergingUserRating(from: refreshedItem)
+
+ #expect(merged.id == "playlist-item:900")
+ #expect(merged.userRating == 9)
+ }
+
+ @Test func hierarchicalItemsAreWatchedOnlyWhenEveryLeafIsWatched() throws {
+ let partiallyWatchedShow = try decodeItem(#"{"ratingKey":"7","type":"show","title":"Show","leafCount":10,"viewedLeafCount":9}"#)
+ let fullyWatchedShow = try decodeItem(#"{"ratingKey":"7","type":"show","title":"Show","leafCount":10,"viewedLeafCount":10}"#)
+
+ #expect(!partiallyWatchedShow.isWatched)
+ #expect(fullyWatchedShow.isWatched)
+ #expect(fullyWatchedShow.supportsWatchedStateMutation)
+ }
+
+ private var providerEndpoints: PlexLibraryProviderEndpoints {
+ PlexLibraryProviderEndpoints(
+ providerIdentifier: "com.plexapp.plugins.library",
+ browseRoutesByLibraryID: [
+ "3": PlexLibraryBrowseRoute(
+ sectionPath: "/provider/sections/3?source=library",
+ contentPath: "/provider/sections/3/all?type=1&source=library"
+ )
+ ],
+ promotedPath: "/provider/promoted?includeTypeFirst=1",
+ continueWatchingPath: "/provider/continue?source=library",
+ searchPath: "/provider/search?includeCollections=1",
+ timelinePath: "/provider/timeline?source=library",
+ scrobblePath: "/provider/played?source=library",
+ unscrobblePath: "/provider/unplayed?source=library",
+ playQueuePath: "/provider/play-queue",
+ ratePath: "/provider/rate?source=library",
+ metadataPath: "/provider/metadata?source=library",
+ removeFromContinueWatchingPath: "/provider/remove-from-continue?source=library",
+ collectionPath: "/provider/collections?source=library",
+ playlistPath: "/provider/playlists?source=library",
+ playlistReadOnly: false,
+ canManage: true,
+ serverAllowsSync: true,
+ supportsDownloadSubscriptions: true
+ )
+ }
+
+ private func mediaProvidersData() -> Data {
+ Self.mediaProvidersData()
+ }
+
+ private static func mediaProvidersData() -> Data {
+ Data(#"""
+ {
+ "MediaContainer": {
+ "allowSync": true,
+ "MediaProvider": [{
+ "identifier": "com.plexapp.plugins.library",
+ "Feature": [{
+ "type": "content",
+ "key": "/provider/sections",
+ "Directory": [{
+ "id": 3,
+ "key": "/provider/sections/3?source=library",
+ "type": "movie",
+ "title": "Movies",
+ "Pivot": [{
+ "id": "library",
+ "key": "/provider/sections/3/all?type=1&source=library",
+ "type": "list",
+ "title": "Library"
+ }]
+ }]
+ }, {
+ "type": "promoted",
+ "key": "/provider/promoted?includeTypeFirst=1"
+ }, {
+ "type": "continuewatching",
+ "key": "/provider/continue?source=library"
+ }, {
+ "type": "search",
+ "key": "/provider/search?includeCollections=1"
+ }, {
+ "type": "timeline",
+ "key": "/provider/timeline?source=library",
+ "scrobbleKey": "/provider/played?source=library",
+ "unscrobbleKey": "/provider/unplayed?source=library"
+ }, {
+ "type": "playqueue",
+ "key": "/provider/play-queue"
+ }, {
+ "type": "rate",
+ "key": "/provider/rate?source=library"
+ }, {
+ "type": "metadata",
+ "key": "/provider/metadata?source=library"
+ }, {
+ "type": "actions",
+ "key": "/provider/actions",
+ "Action": [{
+ "id": "removeFromContinueWatching",
+ "key": "/provider/remove-from-continue?source=library"
+ }]
+ }, {
+ "type": "collection",
+ "key": "/provider/collections?source=library"
+ }, {
+ "type": "playlist",
+ "key": "/provider/playlists?source=library",
+ "readOnly": false
+ }, {
+ "type": "subscribe",
+ "flavor": "download"
+ }, {
+ "type": "manage"
+ }]
+ }]
+ }
+ }
+ """#.utf8)
+ }
+
+ private static func playQueueOnlyMediaProvidersData() -> Data {
+ Data(#"""
+ {
+ "MediaContainer": {
+ "MediaProvider": [{
+ "identifier": "com.plexapp.plugins.library",
+ "Feature": [{
+ "type": "playqueue",
+ "key": "/provider/play-queue?source=library"
+ }]
+ }]
+ }
+ }
+ """#.utf8)
+ }
+
+ private func decodeItem(_ json: String) throws -> PlexMediaItem {
+ try JSONDecoder().decode(PlexMediaItem.self, from: Data(json.utf8))
+ }
+
+ private final class WatchedMutationScenario: @unchecked Sendable {
+ private let lock = NSLock()
+ private let providerData: Data
+ private var watched = false
+ private var personalRating: Double?
+ private var requestCounts: [String: Int] = [:]
+
+ init(providerData: Data = PlexMediaProviderTests.mediaProvidersData()) {
+ self.providerData = providerData
+ }
+
+ func response(for request: URLRequest) throws -> (HTTPURLResponse, Data) {
+ let url = try #require(request.url)
+ let response = try #require(HTTPURLResponse(
+ url: url,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+
+ lock.lock()
+ requestCounts[url.path, default: 0] += 1
+ let data: Data
+ switch url.path {
+ case "/provider/promoted":
+ data = Data(#"{"MediaContainer":{"Hub":[{"hubIdentifier":"home.continue","key":"/hubs/continue-expanded","title":"Continue Watching","more":true,"totalSize":2,"Metadata":[{"ratingKey":"42","type":"movie","title":"Movie","viewCount":0}]},{"hubIdentifier":"movie.recentlyadded.1","title":"Recently Added in Movies","Metadata":[{"ratingKey":"42","type":"movie","title":"Movie","viewCount":0}]}]}}"#.utf8)
+ case "/provider/continue":
+ data = Data(#"{"MediaContainer":{"Hub":[{"hubIdentifier":"continueWatching","key":"/hubs/continue-expanded","title":"Continue Watching","more":true,"totalSize":2,"Metadata":[{"ratingKey":"42","type":"movie","title":"Movie","viewCount":0}]}]}}"#.utf8)
+ case "/hubs/continue-expanded":
+ data = Data(#"{"MediaContainer":{"size":2,"totalSize":2,"Metadata":[{"ratingKey":"42","type":"movie","title":"Movie","viewCount":0},{"ratingKey":"43","type":"movie","title":"Another Movie","viewCount":0}]}}"#.utf8)
+ case "/media/providers":
+ data = providerData
+ case "/provider/played":
+ guard request.httpMethod == "PUT",
+ URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?
+ .contains(where: { $0.name == "source" && $0.value == "library" }) == true else {
+ lock.unlock()
+ Issue.record("Invalid watched request: \(url.absoluteString)")
+ throw URLError(.badURL)
+ }
+ watched = true
+ data = Data()
+ case "/provider/unplayed":
+ guard request.httpMethod == "PUT",
+ URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?
+ .contains(where: { $0.name == "source" && $0.value == "library" }) == true else {
+ lock.unlock()
+ Issue.record("Invalid unwatched request: \(url.absoluteString)")
+ throw URLError(.badURL)
+ }
+ watched = false
+ data = Data()
+ case "/provider/rate":
+ guard request.httpMethod == "PUT",
+ let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems,
+ queryItems.contains(where: { $0.name == "source" && $0.value == "library" }),
+ let ratingValue = queryItems.first(where: { $0.name == "rating" })?.value,
+ let rating = Double(ratingValue) else {
+ lock.unlock()
+ Issue.record("Invalid personal rating request: \(url.absoluteString)")
+ throw URLError(.badURL)
+ }
+ personalRating = rating == 0 ? nil : rating
+ data = Data()
+ case "/provider/remove-from-continue":
+ guard request.httpMethod == "PUT",
+ let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems,
+ queryItems.contains(where: { $0.name == "source" && $0.value == "library" }),
+ queryItems.contains(where: { $0.name == "ratingKey" && $0.value == "42" }) else {
+ lock.unlock()
+ Issue.record("Invalid Continue Watching removal request: \(url.absoluteString)")
+ throw URLError(.badURL)
+ }
+ data = Data()
+ case "/provider/play-queue":
+ guard request.httpMethod == "POST",
+ let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems,
+ queryItems.first(where: { $0.name == "source" })?.value == "library",
+ queryItems.first(where: { $0.name == "type" })?.value == "audio" else {
+ lock.unlock()
+ Issue.record("Invalid audio play-queue request: \(url.absoluteString)")
+ throw URLError(.badURL)
+ }
+ data = Data(#"{"MediaContainer":{"playQueueID":"92","playQueueTotalCount":"1","playQueueSelectedItemID":"602","playQueueSelectedItemOffset":"0","offset":"0","Metadata":[{"ratingKey":"82","key":"/library/metadata/82","type":"track","title":"Chapter 2","playQueueItemID":"602","Media":[]}]}}"#.utf8)
+ case "/provider/play-queue/92":
+ guard request.httpMethod == "GET",
+ let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems,
+ queryItems.first(where: { $0.name == "source" })?.value == "library",
+ queryItems.first(where: { $0.name == "center" })?.value == "602",
+ queryItems.first(where: { $0.name == "includeBefore" })?.value == "1",
+ queryItems.first(where: { $0.name == "includeAfter" })?.value == "1" else {
+ lock.unlock()
+ Issue.record("Invalid play-queue window request: \(url.absoluteString)")
+ throw URLError(.badURL)
+ }
+ data = Data(#"{"MediaContainer":{"playQueueID":"92","playQueueTotalCount":"1","playQueueSelectedItemID":"602","playQueueSelectedItemOffset":"0","offset":"0","Metadata":[{"ratingKey":"82","key":"/library/metadata/82","type":"track","title":"Chapter 2","playQueueItemID":"602","Media":[]}]}}"#.utf8)
+ case "/provider/metadata/42/refresh":
+ guard request.httpMethod == "PUT",
+ URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?
+ .contains(where: { $0.name == "source" && $0.value == "library" }) == true else {
+ lock.unlock()
+ Issue.record("Invalid metadata refresh request: \(url.absoluteString)")
+ throw URLError(.badServerResponse)
+ }
+ data = Data()
+ case "/library/metadata/42":
+ let ratingField = personalRating.map { ",\"userRating\":\($0)" } ?? ""
+ data = Data(#"{"MediaContainer":{"Metadata":[{"ratingKey":"42","type":"movie","title":"Movie","viewCount":\#(watched ? 1 : 0)\#(ratingField)}]}}"#.utf8)
+ default:
+ lock.unlock()
+ Issue.record("Unexpected request: \(url.absoluteString)")
+ throw URLError(.unsupportedURL)
+ }
+ lock.unlock()
+ return (response, data)
+ }
+
+ func requestCount(for path: String) -> Int {
+ lock.lock()
+ defer { lock.unlock() }
+ return requestCounts[path, default: 0]
+ }
+ }
+}
+
+private final class CredentialScopedProviderScenario: @unchecked Sendable {
+ private let lock = NSLock()
+ private let writableToken: String
+ private let writableProviderData: Data
+ private var requestCount = 0
+
+ init(writableToken: String, writableProviderData: Data) {
+ self.writableToken = writableToken
+ self.writableProviderData = writableProviderData
+ }
+
+ var providerRequestCount: Int {
+ lock.withLock { requestCount }
+ }
+
+ func response(for request: URLRequest) throws -> (HTTPURLResponse, Data) {
+ try lock.withLock {
+ let url = try #require(request.url)
+ guard url.path == "/media/providers" else {
+ Issue.record("Unexpected credential-scoped provider request: \(request)")
+ throw URLError(.unsupportedURL)
+ }
+ requestCount += 1
+ let token = request.value(forHTTPHeaderField: "X-Plex-Token")
+ let data = token == writableToken
+ ? writableProviderData
+ : Data(#"{"MediaContainer":{"MediaProvider":[{"identifier":"com.plexapp.plugins.library","Feature":[]}]}}"#.utf8)
+ let response = try #require(HTTPURLResponse(
+ url: url,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, data)
+ }
+ }
+}
+
+private func mediaProviderDownloadLibrary() -> PlexLibrary {
+ PlexLibrary(
+ id: "1",
+ title: "Movies",
+ type: .movie,
+ compositePath: nil,
+ artPath: nil,
+ thumbPath: nil,
+ itemCount: 1,
+ secondaryCount: nil,
+ secondaryCountLabel: nil,
+ updatedAt: nil,
+ scannedAt: nil,
+ contentChangedAt: nil,
+ latestAddedAt: nil,
+ latestItemTitle: nil,
+ allowSync: true
+ )
+}
+
+private func makeMediaProviderMockSession(
+ handler: @escaping @Sendable (URLRequest) throws -> (HTTPURLResponse, Data)
+) -> URLSession {
+ MediaProviderMockURLProtocol.requestHandler = handler
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.protocolClasses = [MediaProviderMockURLProtocol.self]
+ return URLSession(configuration: configuration)
+}
+
+private final class MediaProviderMockURLProtocol: URLProtocol, @unchecked Sendable {
+ nonisolated(unsafe) static var requestHandler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))?
+
+ override static func canInit(with request: URLRequest) -> Bool {
+ true
+ }
+
+ override static func canonicalRequest(for request: URLRequest) -> URLRequest {
+ request
+ }
+
+ override func startLoading() {
+ guard let handler = Self.requestHandler else {
+ client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
+ return
+ }
+
+ do {
+ let (response, data) = try handler(request)
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: data)
+ client?.urlProtocolDidFinishLoading(self)
+ } catch {
+ client?.urlProtocol(self, didFailWithError: error)
+ }
+ }
+
+ override func stopLoading() {}
+}
diff --git a/PlexBarTests/PlexMediaRequestTests.swift b/PlexBarTests/PlexMediaRequestTests.swift
new file mode 100644
index 0000000..c745e16
--- /dev/null
+++ b/PlexBarTests/PlexMediaRequestTests.swift
@@ -0,0 +1,727 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+@Suite(.serialized)
+struct PlexMediaRequestTests {
+ @Test func promotedHubsUseTheAdvertisedEndpointAndPreserveItsQuery() async throws {
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, promotedHubsData())
+ }
+
+ let hubs = try await PlexAPIClient(session: session).fetchHubs(
+ endpointPath: "/provider/promoted?includeTypeFirst=1&count=4",
+ using: try configuration,
+ count: 24
+ )
+
+ let request = try #require(capture.request)
+ let components = try #require(
+ request.url.flatMap { URLComponents(url: $0, resolvingAgainstBaseURL: false) }
+ )
+ #expect(components.path == "/provider/promoted")
+ #expect(components.queryItems == [
+ URLQueryItem(name: "includeTypeFirst", value: "1"),
+ URLQueryItem(name: "count", value: "24"),
+ ])
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Token") == "server-token")
+
+ let hub = try #require(hubs.first)
+ #expect(hub.id == "home.movies.recent")
+ #expect(hub.title == "Recently Added Movies")
+ #expect(hub.key == "/hubs/home/recentlyAdded?type=1")
+ #expect(hub.style == "shelf")
+ #expect(hub.size == 1)
+ #expect(hub.totalSize == 12)
+ #expect(hub.more)
+ #expect(hub.promoted)
+ #expect(hub.metadata.map(\.title) == ["Charade"])
+ }
+
+ @Test func playbackHomeHubsPreferPosterArtworkAndEpisodeUsesShowPoster() throws {
+ let data = Data(#"""
+ {
+ "MediaContainer": {
+ "Hub": [
+ {
+ "hubIdentifier": "home.continue",
+ "title": "Continue Watching",
+ "Metadata": []
+ },
+ {
+ "hubIdentifier": "home.onDeck",
+ "title": "On Deck",
+ "Metadata": [{
+ "ratingKey": "42",
+ "title": "Episode Title",
+ "type": "episode",
+ "thumb": "/library/metadata/42/thumb",
+ "parentThumb": "/library/metadata/41/thumb",
+ "grandparentThumb": "/library/metadata/40/thumb",
+ "Media": []
+ }]
+ },
+ {
+ "hubIdentifier": "home.movies.recent",
+ "title": "Recently Added Movies",
+ "Metadata": []
+ }
+ ]
+ }
+ }
+ """#.utf8)
+
+ let hubs = try JSONDecoder().decode(PlexHubEnvelope.self, from: data).mediaContainer.hubs
+ #expect(hubs[0].prefersPosterArtwork)
+ #expect(hubs[1].prefersPosterArtwork)
+ #expect(!hubs[2].prefersPosterArtwork)
+ #expect(hubs[1].metadata[0].posterArtworkPath == "/library/metadata/40/thumb")
+ }
+
+ @Test func globalSearchUsesTheAdvertisedEndpointAndPreservesItsQuery() async throws {
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, Data(#"""
+ {
+ "MediaContainer": {
+ "Hub": [
+ {
+ "hubIdentifier": "show",
+ "title": "Shows",
+ "type": "show",
+ "Directory": [
+ {
+ "key": "/hubs/search?query=simpsons",
+ "title": "Search All Libraries"
+ },
+ {
+ "ratingKey": "100",
+ "key": "/library/metadata/100/children",
+ "title": "The Simpsons",
+ "type": "show",
+ "reason": "section",
+ "reasonTitle": "TV Shows",
+ "reasonID": 2
+ }
+ ]
+ },
+ {
+ "hubIdentifier": "movie",
+ "title": "Movies",
+ "type": "movie",
+ "Metadata": [{
+ "ratingKey": "200",
+ "key": "/library/metadata/200",
+ "title": "The Simpsons Movie",
+ "type": "movie",
+ "reason": "show",
+ "reasonTitle": "The Simpsons",
+ "reasonID": "100"
+ }]
+ },
+ {
+ "hubIdentifier": "empty",
+ "title": "Empty",
+ "Metadata": []
+ }
+ ]
+ }
+ }
+ """#.utf8))
+ }
+
+ let hubs = try await PlexAPIClient(session: session).fetchSearchHubs(
+ query: " simpsons ",
+ endpointPath: "/provider/search?includeCollections=1&limit=2",
+ using: try configuration,
+ limit: 8
+ )
+
+ let request = try #require(capture.request)
+ let components = try #require(
+ request.url.flatMap { URLComponents(url: $0, resolvingAgainstBaseURL: false) }
+ )
+ #expect(components.path == "/provider/search")
+ #expect(components.queryItems == [
+ URLQueryItem(name: "includeCollections", value: "1"),
+ URLQueryItem(name: "query", value: "simpsons"),
+ URLQueryItem(name: "limit", value: "8"),
+ ])
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Token") == "server-token")
+ #expect(hubs.map(\.title) == ["Shows", "Movies"])
+ #expect(hubs[0].metadata.map(\.title) == ["The Simpsons"])
+ #expect(hubs[0].metadata[0].reason == "section")
+ #expect(hubs[0].metadata[0].reasonTitle == "TV Shows")
+ #expect(hubs[0].metadata[0].reasonID == "2")
+ #expect(hubs[0].metadata[0].subtitle == "TV Shows · Show")
+ #expect(hubs[1].metadata[0].reasonID == "100")
+ }
+
+ @MainActor
+ @Test func failedHomeRefreshKeepsTheExistingServerFeedVisible() async throws {
+ let suiteName = "PlexBarTests.failedHomeRefreshKeepsTheExistingServerFeedVisible"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let session = makeMediaMockSession { request in
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ switch request.url?.path {
+ case "/media/providers":
+ return (response, libraryProviderData())
+ case "/provider/promoted":
+ return (response, promotedHubsData())
+ case "/provider/continue":
+ return (response, Data(#"{"MediaContainer":{"Hub":[]}}"#.utf8))
+ default:
+ Issue.record("Unexpected request: \(request.url?.absoluteString ?? "nil")")
+ return (response, Data(#"{"MediaContainer":{}}"#.utf8))
+ }
+ }
+ let settings = PlexSettingsStore(
+ defaults: defaults,
+ credentialStore: PlexMemoryCredentialStore(
+ credentials: PlexStoredCredentials(userToken: "user-token", serverToken: "server-token")
+ ),
+ initialCredentials: PlexStoredCredentials(userToken: "user-token", serverToken: "server-token")
+ )
+ settings.selectedServerIdentifier = "server-id"
+ settings.selectedServerName = "Server"
+ let connectionStore = PlexConnectionStore(settings: settings)
+ connectionStore.activeConnection = PlexResolvedConnection(
+ serverID: "server-id",
+ url: try #require(URL(string: "https://plex.local:32400")),
+ kind: .local,
+ validatedAt: Date()
+ )
+ let store = PlexBrowserStore(
+ connectionStore: connectionStore,
+ client: PlexAPIClient(session: session)
+ )
+
+ await store.loadHomeHubs()
+ #expect(store.homeHubs.map(\.title) == ["Recently Added Movies"])
+
+ MediaMockURLProtocol.requestHandler = { _ in
+ throw PlexAPIError.invalidResponse
+ }
+ await store.loadHomeHubs(forceRefresh: true)
+
+ #expect(store.homeHubs.map(\.title) == ["Recently Added Movies"])
+ #expect(store.homeHubsErrorMessage != nil)
+ }
+
+ @Test func fetchMediaPageUsesPlexPaginationHeadersAndDecodesItems() async throws {
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: ["X-Plex-Container-Total-Size": "250"]
+ ))
+ let data = Data(#"""
+ {
+ "MediaContainer": {
+ "offset": 100,
+ "Metadata": [
+ { "ratingKey": "42", "title": "Charade", "type": "movie", "year": 1963, "Media": [] }
+ ]
+ }
+ }
+ """#.utf8)
+ return (response, data)
+ }
+
+ let page = try await PlexAPIClient(session: session).fetchMediaPage(
+ libraryID: "1",
+ using: try configuration,
+ start: 100,
+ size: 50
+ )
+
+ #expect(page.items.map { $0.title } == ["Charade"])
+ #expect(page.offset == 100)
+ #expect(page.totalSize == 250)
+
+ let request = try #require(capture.request)
+ #expect(request.url?.path == "/library/sections/1/all")
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Container-Start") == "100")
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Container-Size") == "50")
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Token") == "server-token")
+ let components = try #require(request.url.flatMap { URLComponents(url: $0, resolvingAgainstBaseURL: false) })
+ #expect(components.queryItems?.contains { $0.name == "sort" } != true)
+ }
+
+ @Test func metadataRequestExplicitlyIncludesCinematicMetadata() async throws {
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, Data(#"""
+ {
+ "MediaContainer": {
+ "Metadata": [{
+ "ratingKey": "42",
+ "title": "Episode",
+ "type": "episode",
+ "Image": [{
+ "type": "clearLogo",
+ "url": "/library/metadata/40/clearLogo/1700000000",
+ "alt": "Example Show"
+ }],
+ "Chapter": [{
+ "id": 8,
+ "index": 1,
+ "startTimeOffset": 0,
+ "endTimeOffset": 30000,
+ "thumb": "/library/media/99/chapterImages/1"
+ }],
+ "Marker": [{
+ "id": 7,
+ "type": "intro",
+ "startTimeOffset": 30000,
+ "endTimeOffset": 92000
+ }],
+ "Rating": [{
+ "image": "imdb://image.rating",
+ "type": "audience",
+ "value": 7.8
+ }],
+ "Guid": [{ "id": "imdb://tt1234567" }]
+ }]
+ }
+ }
+ """#.utf8))
+ }
+
+ let item = try await PlexAPIClient(session: session).fetchMediaMetadata(
+ ratingKey: "42",
+ using: try configuration
+ )
+
+ let request = try #require(capture.request)
+ let components = try #require(
+ request.url.flatMap { URLComponents(url: $0, resolvingAgainstBaseURL: false) }
+ )
+ #expect(components.path == "/library/metadata/42")
+ #expect(components.queryItems == [
+ URLQueryItem(name: "includeOptionalElements", value: "Chapter,Image,Marker,Rating"),
+ URLQueryItem(name: "includeGuids", value: "1"),
+ ])
+ #expect(item.clearLogoPath == "/library/metadata/40/clearLogo/1700000000")
+ #expect(item.images.first?.alt == "Example Show")
+ #expect(item.markers.map(\.id) == ["7"])
+ #expect(item.chapters.map(\.id) == ["8"])
+ #expect(item.chapters.first?.startTimeOffset == 0)
+ #expect(item.chapters.first?.endTimeOffset == 30_000)
+ #expect(item.ratings.count == 1)
+ #expect(item.ratings.first?.image == "imdb://image.rating")
+ #expect(item.ratings.first?.type == "audience")
+ #expect(item.ratings.first?.value == 7.8)
+ #expect(item.guids.map(\.id) == ["imdb://tt1234567"])
+ }
+
+ @MainActor
+ @Test func mediaRouteResolvesUncachedHistoryMetadataAndRetainsItForNavigation() async throws {
+ let suiteName = "PlexBarTests.mediaRouteResolvesUncachedHistoryMetadata"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let session = makeMediaMockSession { request in
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, Data(#"{"MediaContainer":{"Metadata":[{"ratingKey":"900","key":"/library/metadata/900/children","type":"show","title":"Severance"}]}}"#.utf8))
+ }
+ let credentials = PlexStoredCredentials(userToken: "user-token", serverToken: "server-token")
+ let settings = PlexSettingsStore(
+ defaults: defaults,
+ credentialStore: PlexMemoryCredentialStore(credentials: credentials),
+ initialCredentials: credentials
+ )
+ settings.selectedServerIdentifier = "server-id"
+ settings.selectedServerName = "Server"
+ let connectionStore = PlexConnectionStore(settings: settings)
+ connectionStore.activeConnection = PlexResolvedConnection(
+ serverID: "server-id",
+ url: try #require(URL(string: "https://plex.local:32400")),
+ kind: .local,
+ validatedAt: Date()
+ )
+ let store = PlexBrowserStore(
+ connectionStore: connectionStore,
+ client: PlexAPIClient(session: session)
+ )
+ let route = try #require(PlexMediaRoute(ratingKey: "900"))
+
+ #expect(store.item(for: route) == nil)
+ let resolvedItem = try await store.resolveItem(for: route)
+ #expect(resolvedItem.title == "Severance")
+ #expect(store.item(for: route) == resolvedItem)
+
+ MediaMockURLProtocol.requestHandler = { _ in
+ throw PlexAPIError.invalidResponse
+ }
+ #expect(try await store.resolveItem(for: route) == resolvedItem)
+
+ store.resetResolvedMediaItems()
+ #expect(store.item(for: route) == nil)
+ }
+
+ @Test func playbackDecisionBuildsAuthenticatedDirectPlayURL() async throws {
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, directPlayDecisionData())
+ }
+
+ let item = try JSONDecoder().decode(
+ PlexMediaItem.self,
+ from: playableItemData()
+ )
+ let plan = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try configuration,
+ capabilities: PlexPlaybackCapabilities(
+ directPlayContainers: ["mp4"],
+ directPlayVideoCodecs: ["h264"],
+ directPlayAudioCodecs: ["aac"]
+ )
+ )
+
+ #expect(plan.method == PlexPlaybackPlan.Method.directPlay)
+ #expect(plan.mediaKind == .video)
+ #expect(plan.startTime == 5)
+ #expect(plan.duration == 60)
+ #expect(plan.url.path == "/library/parts/7/file.mp4")
+
+ let playbackComponents = try #require(URLComponents(url: plan.url, resolvingAgainstBaseURL: false))
+ let playbackQueryItems = playbackComponents.queryItems ?? []
+ let hasToken = playbackQueryItems.contains { item in
+ item.name == "X-Plex-Token" && item.value == "server-token"
+ }
+ let hasSessionIdentifier = playbackQueryItems.contains { item in
+ item.name == "X-Plex-Session-Identifier" && item.value == plan.sessionIdentifier
+ }
+ let hasClientIdentifier = playbackQueryItems.contains { item in
+ item.name == "X-Plex-Client-Identifier" && item.value == "client-123"
+ }
+ #expect(hasToken)
+ #expect(hasSessionIdentifier)
+ #expect(hasClientIdentifier)
+
+ let decisionRequest = try #require(capture.request)
+ #expect(decisionRequest.url?.path == "/video/:/transcode/universal/decision")
+ #expect(decisionRequest.value(forHTTPHeaderField: "X-Plex-Session-Identifier") == plan.sessionIdentifier)
+ #expect(decisionRequest.value(forHTTPHeaderField: "X-Plex-Client-Profile-Name") == "generic")
+ let profile = decisionRequest.value(forHTTPHeaderField: "X-Plex-Client-Profile-Extra")
+ #expect(profile?.contains("add-direct-play-profile") == true)
+ #expect(profile?.contains("subtitleCodec=*") == true)
+ }
+
+ @Test func reportTimelinePostsCanonicalStoppedQueueState() async throws {
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, Data(#"{"MediaContainer":{}}"#.utf8))
+ }
+
+ _ = try await PlexAPIClient(session: session).reportTimeline(
+ PlexTimelineUpdate(
+ ratingKey: "42",
+ state: .stopped,
+ time: 12_000,
+ duration: 60_000,
+ sessionIdentifier: "session-123",
+ playQueueItemID: "queue-item-9",
+ continuing: true
+ ),
+ endpointPath: "/provider/timeline?source=library",
+ using: try configuration
+ )
+
+ let request = try #require(capture.request)
+ #expect(request.httpMethod == "POST")
+ #expect(request.url?.path == "/provider/timeline")
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Session-Identifier") == "session-123")
+ let components = try #require(request.url.flatMap { URLComponents(url: $0, resolvingAgainstBaseURL: false) })
+ #expect(components.queryItems?.contains { $0.name == "source" && $0.value == "library" } == true)
+ #expect(components.queryItems?.contains { $0.name == "ratingKey" && $0.value == "42" } == true)
+ #expect(components.queryItems?.contains { $0.name == "state" && $0.value == "stopped" } == true)
+ #expect(components.queryItems?.contains { $0.name == "time" && $0.value == "12000" } == true)
+ #expect(
+ components.queryItems?.contains {
+ $0.name == "playQueueItemID" && $0.value == "queue-item-9"
+ } == true
+ )
+ #expect(components.queryItems?.contains { $0.name == "continuing" && $0.value == "1" } == true)
+ #expect(components.queryItems?.contains { $0.name == "offline" } != true)
+ }
+
+ @Test func offlineTimelineExplicitlyIdentifiesDeferredPlayback() async throws {
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, Data(#"{"MediaContainer":{}}"#.utf8))
+ }
+
+ _ = try await PlexAPIClient(session: session).reportTimeline(
+ PlexTimelineUpdate(
+ ratingKey: "42",
+ state: .stopped,
+ time: 48_000,
+ duration: 60_000,
+ sessionIdentifier: "offline-session",
+ offline: true
+ ),
+ endpointPath: "/provider/timeline",
+ using: try configuration
+ )
+
+ let components = try #require(capture.request?.url.flatMap {
+ URLComponents(url: $0, resolvingAgainstBaseURL: false)
+ })
+ #expect(components.queryItems?.contains { $0.name == "offline" && $0.value == "1" } == true)
+ }
+
+ @Test func transcodePlanCarriesRequiredPlexContextIntoHLSURL() async throws {
+ let session = makeMediaMockSession { request in
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, transcodeDecisionData())
+ }
+
+ let item = try JSONDecoder().decode(PlexMediaItem.self, from: playableItemData())
+ let plan = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try configuration,
+ capabilities: PlexPlaybackCapabilities(
+ directPlayContainers: ["mp4"],
+ directPlayVideoCodecs: ["h264"],
+ directPlayAudioCodecs: ["aac"]
+ )
+ )
+
+ #expect(plan.method == .transcode)
+ #expect(plan.url.path == "/video/:/transcode/universal/start.m3u8")
+ let components = try #require(URLComponents(url: plan.url, resolvingAgainstBaseURL: false))
+ let queryItems = components.queryItems ?? []
+ #expect(queryItems.contains { $0.name == "X-Plex-Client-Identifier" && $0.value == "client-123" })
+ #expect(queryItems.contains { $0.name == "X-Plex-Client-Profile-Name" && $0.value == "generic" })
+ #expect(queryItems.contains { $0.name == "X-Plex-Client-Profile-Extra" })
+ #expect(queryItems.contains { $0.name == "X-Plex-Session-Identifier" })
+ #expect(queryItems.contains { $0.name == "X-Plex-Token" && $0.value == "server-token" })
+ }
+
+ private var configuration: PlexConnectionConfiguration {
+ get throws {
+ PlexConnectionConfiguration(
+ serverURL: try #require(PlexURLBuilder.normalizeServerURL("https://plex.local:32400")),
+ token: "server-token",
+ clientContext: PlexClientContext(clientIdentifier: "client-123")
+ )
+ }
+ }
+}
+
+private func promotedHubsData() -> Data {
+ Data(#"""
+ {
+ "MediaContainer": {
+ "Hub": [{
+ "hubIdentifier": "home.movies.recent",
+ "hubKey": "/library/metadata/42",
+ "key": "/hubs/home/recentlyAdded?type=1",
+ "title": "Recently Added Movies",
+ "type": "movie",
+ "style": "shelf",
+ "size": "1",
+ "totalSize": 12,
+ "more": "1",
+ "promoted": true,
+ "Metadata": [{
+ "ratingKey": "42",
+ "key": "/library/metadata/42",
+ "title": "Charade",
+ "type": "movie",
+ "Media": []
+ }]
+ }]
+ }
+ }
+ """#.utf8)
+}
+
+private func libraryProviderData() -> Data {
+ Data(#"""
+ {
+ "MediaContainer": {
+ "MediaProvider": [{
+ "identifier": "com.plexapp.plugins.library",
+ "Feature": [{
+ "type": "promoted",
+ "key": "/provider/promoted?includeTypeFirst=1"
+ }, {
+ "type": "continuewatching",
+ "key": "/provider/continue"
+ }, {
+ "type": "search",
+ "key": "/provider/search"
+ }]
+ }]
+ }
+ }
+ """#.utf8)
+}
+
+func directPlayDecisionData() -> Data {
+ Data(#"""
+ {
+ "MediaContainer": {
+ "generalDecisionCode": "1000",
+ "Metadata": [{
+ "ratingKey": "42",
+ "title": "Charade",
+ "Media": [{
+ "id": "901",
+ "selected": "1",
+ "Part": [{
+ "id": "902",
+ "selected": "1",
+ "decision": "directplay",
+ "key": "/library/parts/7/file.mp4",
+ "Stream": [{ "id": "903", "streamType": "1", "selected": "1" }]
+ }]
+ }]
+ }]
+ }
+ }
+ """#.utf8)
+}
+
+func playableItemData() -> Data {
+ Data(#"""
+ {
+ "ratingKey": "42",
+ "title": "Charade",
+ "duration": 60000,
+ "viewOffset": 5000,
+ "Media": [{ "Part": [{ "key": "/library/parts/7/file.mp4" }] }]
+ }
+ """#.utf8)
+}
+
+func transcodeDecisionData() -> Data {
+ Data(#"""
+ {
+ "MediaContainer": {
+ "generalDecisionCode": "1001",
+ "Metadata": [{
+ "ratingKey": "42",
+ "title": "Charade",
+ "Media": [{
+ "selected": "1",
+ "Part": [{
+ "selected": "1",
+ "decision": "transcode",
+ "Stream": [{ "streamType": "1", "decision": "transcode" }]
+ }]
+ }]
+ }]
+ }
+ }
+ """#.utf8)
+}
+
+func makeMediaMockSession(
+ handler: @escaping @Sendable (URLRequest) throws -> (HTTPURLResponse, Data)
+) -> URLSession {
+ MediaMockURLProtocol.requestHandler = handler
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.protocolClasses = [MediaMockURLProtocol.self]
+ return URLSession(configuration: configuration)
+}
+
+final class MediaMockURLProtocol: URLProtocol, @unchecked Sendable {
+ nonisolated(unsafe) static var requestHandler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))?
+
+ override static func canInit(with request: URLRequest) -> Bool {
+ true
+ }
+
+ override static func canonicalRequest(for request: URLRequest) -> URLRequest {
+ request
+ }
+
+ override func startLoading() {
+ guard let handler = Self.requestHandler else {
+ client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
+ return
+ }
+
+ do {
+ let (response, data) = try handler(request)
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: data)
+ client?.urlProtocolDidFinishLoading(self)
+ } catch {
+ client?.urlProtocol(self, didFailWithError: error)
+ }
+ }
+
+ override func stopLoading() {}
+}
diff --git a/PlexBarTests/PlexMediaSelectionTests.swift b/PlexBarTests/PlexMediaSelectionTests.swift
new file mode 100644
index 0000000..a698fa7
--- /dev/null
+++ b/PlexBarTests/PlexMediaSelectionTests.swift
@@ -0,0 +1,285 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+@Suite
+struct PlexMediaSelectionTests {
+ @Test func streamSelectionRequestUsesTheSharedDocumentedPartContract() {
+ let parameters = PlexMediaSelectionRequestParameters(
+ partID: 700,
+ audioStreamID: 21,
+ subtitleStreamID: 0,
+ allParts: true
+ )
+
+ #expect(parameters.hasSelection)
+ #expect(parameters.path == "/library/parts/700")
+ #expect(parameters.queryItems == [
+ URLQueryItem(name: "audioStreamID", value: "21"),
+ URLQueryItem(name: "subtitleStreamID", value: "0"),
+ URLQueryItem(name: "allParts", value: "1"),
+ ])
+
+ let empty = PlexMediaSelectionRequestParameters(
+ partID: 700,
+ audioStreamID: nil,
+ subtitleStreamID: nil,
+ allParts: false
+ )
+ #expect(!empty.hasSelection)
+ #expect(empty.queryItems == [URLQueryItem(name: "allParts", value: "0")])
+ }
+
+ @Test func subtitleOffsetRequestUsesTheDocumentedStreamContract() {
+ let parameters = PlexSubtitleOffsetRequestParameters(
+ streamID: 31,
+ milliseconds: -200
+ )
+
+ #expect(parameters.path == "/library/streams/31")
+ #expect(parameters.queryItems == [
+ URLQueryItem(name: "offset", value: "-200"),
+ ])
+ }
+
+ @Test func exposesOffsetOnlyForTheSelectedExternalTextSubtitle() throws {
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "42",
+ "title": "Movie",
+ "Media": [{
+ "Part": [{
+ "id": "700",
+ "Stream": [
+ {"id":"31","streamType":"3","codec":"srt","selected":"1","location":"external","offset":"-150"},
+ {"id":"32","streamType":"3","codec":"srt","location":"embedded"},
+ {"id":"33","streamType":"3","codec":"pgs","location":"external"}
+ ]
+ }]
+ }]
+ }
+ """#)
+
+ let selection = PlexPlaybackMediaSelection(
+ item: item,
+ source: PlexPlaybackSource(mediaIndex: 0, partIndex: 0)
+ )
+
+ #expect(selection.subtitleOffsetSelection == PlexSubtitleOffsetSelection(
+ streamID: 31,
+ milliseconds: -150
+ ))
+ #expect(selection.subtitleOffsetSelection?.displayValue == "−150 ms")
+ #expect(selection.subtitleOffsetSelection?.adjusted(by: 100) == -50)
+ }
+
+ @Test func derivesNativeMenuOptionsFromTheSelectedPlexPart() throws {
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "42",
+ "title": "Movie",
+ "Media": [{
+ "Part": [{
+ "id": "700",
+ "Stream": [
+ {"id":"21","streamType":"2","displayTitle":"English (AC3 5.1)","languageCode":"eng","selected":"1","codec":"ac3","channels":"6"},
+ {"id":"22","streamType":"2","language":"French","languageCode":"fra","codec":"aac","channels":"2","visualImpaired":"1"},
+ {"id":"31","streamType":"3","displayTitle":"English","languageCode":"eng","selected":"1","hearingImpaired":"1"},
+ {"id":"32","streamType":"3","language":"Spanish","languageCode":"es-419","forced":"1"}
+ ]
+ }]
+ }]
+ }
+ """#)
+
+ let selection = PlexPlaybackMediaSelection(
+ item: item,
+ source: PlexPlaybackSource(mediaIndex: 0, partIndex: 0)
+ )
+
+ #expect(selection.partID == 700)
+ #expect(selection.audioOptions.map(\.id) == [21, 22])
+ #expect(selection.audioOptions[0].isSelected)
+ #expect(selection.audioOptions.map(\.languageTag) == ["eng", "fra"])
+ #expect(selection.audioOptions[0].title.contains("English (AC3 5.1)"))
+ #expect(selection.audioOptions[0].title.contains("6 ch"))
+ #expect(selection.audioOptions[1].isVisualImpaired)
+ #expect(selection.subtitleOptions.map(\.id) == [31, 32])
+ #expect(selection.subtitleOptions.map(\.languageTag) == ["eng", "es-419"])
+ #expect(selection.subtitleOptions[0].isSelected)
+ #expect(selection.subtitleOptions[0].isHearingImpaired)
+ #expect(selection.subtitleOptions[1].isForced)
+ #expect(selection.subtitleOptions[0].title.contains("SDH"))
+ #expect(selection.subtitleOptions[1].title.contains("Forced"))
+ #expect(selection.hasActionMenuItems)
+ #expect(selection.canSelectAudioStream(22))
+ #expect(!selection.canSelectAudioStream(21))
+ #expect(!selection.canSelectAudioStream(999))
+ #expect(selection.canSelectSubtitleStream(32))
+ #expect(selection.canSelectSubtitleStream(nil))
+ #expect(!selection.canSelectSubtitleStream(31))
+ #expect(!selection.canSelectSubtitleStream(999))
+ }
+
+ @Test func languageMetadataDoesNotGuessMissingOrMalformedBCP47Tags() throws {
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "42",
+ "title": "Movie",
+ "Media": [{
+ "Part": [{
+ "id": "700",
+ "Stream": [
+ {"id":"21","streamType":"2","language":"English"},
+ {"id":"22","streamType":"2","languageCode":"English US"},
+ {"id":"31","streamType":"3","languageCode":"en-US"}
+ ]
+ }]
+ }]
+ }
+ """#)
+
+ let selection = PlexPlaybackMediaSelection(
+ item: item,
+ source: PlexPlaybackSource(mediaIndex: 0, partIndex: 0)
+ )
+
+ #expect(selection.audioOptions.map(\.languageTag) == [nil, nil])
+ #expect(selection.subtitleOptions.map(\.languageTag) == ["en-US"])
+ #expect(!selection.canSelectSubtitleStream(nil))
+ }
+
+ @Test func nativeAvailabilityRejectsAnOutgoingPlayerItemGeneration() {
+ var state = PlexNativeMediaSelectionState()
+ let firstGeneration = state.generation
+ let firstAvailability = PlexNativeMediaSelectionAvailability(
+ audioOptionCount: 2,
+ subtitleOptionCount: 0
+ )
+
+ let acceptedFirst = state.accept(firstAvailability, generation: firstGeneration)
+ #expect(acceptedFirst)
+ #expect(state.availability == firstAvailability)
+ let acceptedDuplicate = state.accept(firstAvailability, generation: firstGeneration)
+ #expect(!acceptedDuplicate)
+
+ state.beginReload()
+ #expect(state.generation == firstGeneration + 1)
+ #expect(state.availability == nil)
+ let acceptedStale = state.accept(firstAvailability, generation: firstGeneration)
+ #expect(!acceptedStale)
+
+ let successorAvailability = PlexNativeMediaSelectionAvailability(
+ audioOptionCount: 0,
+ subtitleOptionCount: 1
+ )
+ let acceptedSuccessor = state.accept(
+ successorAvailability,
+ generation: state.generation
+ )
+ #expect(acceptedSuccessor)
+ #expect(state.availability == successorAvailability)
+ }
+
+ @Test func serverManagedMenusWaitForInspectionAndExposeOnlyIncompleteAVKitGroups() throws {
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "42",
+ "title": "Movie",
+ "Media": [{
+ "Part": [{
+ "id": "700",
+ "Stream": [
+ {"id":"21","streamType":"2","language":"English","selected":"1"},
+ {"id":"22","streamType":"2","language":"French"},
+ {"id":"31","streamType":"3","language":"English","selected":"1"},
+ {"id":"32","streamType":"3","language":"Spanish"}
+ ]
+ }]
+ }]
+ }
+ """#)
+ let selection = PlexPlaybackMediaSelection(
+ item: item,
+ source: PlexPlaybackSource(mediaIndex: 0, partIndex: 0)
+ )
+
+ let inspectionPending = PlexServerManagedMediaSelection(
+ selection: selection,
+ nativeAvailability: nil
+ )
+ #expect(!inspectionPending.hasChoices)
+
+ let allNative = PlexServerManagedMediaSelection(
+ selection: selection,
+ nativeAvailability: PlexNativeMediaSelectionAvailability(
+ audioOptionCount: 2,
+ subtitleOptionCount: 2
+ )
+ )
+ #expect(!allNative.hasChoices)
+
+ let subtitlesRequirePlex = PlexServerManagedMediaSelection(
+ selection: selection,
+ nativeAvailability: PlexNativeMediaSelectionAvailability(
+ audioOptionCount: 2,
+ subtitleOptionCount: 0
+ )
+ )
+ #expect(subtitlesRequirePlex.audioOptions.isEmpty)
+ #expect(subtitlesRequirePlex.subtitleOptions.map(\.id) == [31, 32])
+ #expect(subtitlesRequirePlex.canSelectSubtitleStream(nil))
+ #expect(subtitlesRequirePlex.canSelectSubtitleStream(32))
+ #expect(!subtitlesRequirePlex.canSelectSubtitleStream(31))
+
+ let audioRequiresPlex = PlexServerManagedMediaSelection(
+ selection: selection,
+ nativeAvailability: PlexNativeMediaSelectionAvailability(
+ audioOptionCount: 0,
+ subtitleOptionCount: 2
+ )
+ )
+ #expect(audioRequiresPlex.audioOptions.map(\.id) == [21, 22])
+ #expect(audioRequiresPlex.subtitleOptions.isEmpty)
+ #expect(audioRequiresPlex.canSelectAudioStream(22))
+ #expect(!audioRequiresPlex.canSelectAudioStream(21))
+
+ let partiallyNative = PlexServerManagedMediaSelection(
+ selection: selection,
+ nativeAvailability: PlexNativeMediaSelectionAvailability(
+ audioOptionCount: 1,
+ subtitleOptionCount: 1
+ )
+ )
+ #expect(partiallyNative.audioOptions.map(\.id) == [21, 22])
+ #expect(partiallyNative.subtitleOptions.map(\.id) == [31, 32])
+ }
+
+ @Test func multipartSelectionUsesTheSelectedPartForServerStreamChanges() throws {
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "42",
+ "title": "Movie",
+ "Media": [{
+ "Part": [
+ {"id":"700","Stream":[]},
+ {"id":"701","selected":"1","Stream":[{"id":"44","streamType":"3","language":"English"}]}
+ ]
+ }]
+ }
+ """#)
+
+ let selection = PlexPlaybackMediaSelection(
+ item: item,
+ source: PlexPlaybackSource(mediaIndex: 0, partIndex: -1)
+ )
+
+ #expect(selection.partID == 701)
+ #expect(selection.subtitleOptions.map(\.id) == [44])
+ }
+
+ private func decodeItem(_ json: String) throws -> PlexMediaItem {
+ try JSONDecoder().decode(PlexMediaItem.self, from: Data(json.utf8))
+ }
+}
diff --git a/PlexBarTests/PlexNativeMediaFactsTests.swift b/PlexBarTests/PlexNativeMediaFactsTests.swift
new file mode 100644
index 0000000..91377f8
--- /dev/null
+++ b/PlexBarTests/PlexNativeMediaFactsTests.swift
@@ -0,0 +1,252 @@
+import AudioToolbox
+import CoreMedia
+import Foundation
+import Testing
+@testable import PlexBar
+
+struct PlexNativeMediaFactsTests {
+ @Test func extractsDeliveredHDRVideoAndMultichannelAudioFacts() throws {
+ let videoDescription = try makeVideoDescription(
+ codec: kCMVideoCodecType_HEVC,
+ width: 3840,
+ height: 2160,
+ transferFunction: kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ,
+ includesHDR10StaticMetadata: true
+ )
+ let audioDescription = try makeAudioDescription(
+ formatID: kAudioFormatEnhancedAC3,
+ channelCount: 6,
+ layoutTag: kAudioChannelLayoutTag_MPEG_5_1_A
+ )
+
+ let facts = try #require(PlexNativeMediaInspector.facts(
+ videoFormatDescription: videoDescription,
+ audioFormatDescription: audioDescription,
+ videoFrameRate: 23.976,
+ videoBitRate: 18_500_000,
+ audioBitRate: 640_000
+ ))
+
+ #expect(facts.videoWidth == 3840)
+ #expect(facts.videoHeight == 2160)
+ #expect(facts.videoFrameRate == 23.976)
+ #expect(facts.videoCodec == "HEVC")
+ #expect(facts.dynamicRange == .hdr10)
+ #expect(facts.videoBitRate == 18_500_000)
+ #expect(facts.audioCodec == "E-AC-3")
+ #expect(facts.audioChannelCount == 6)
+ #expect(facts.audioLayout == .surround5_1)
+ #expect(facts.audioSampleRate == 48_000)
+ #expect(facts.audioBitRate == 640_000)
+ #expect(facts.displayComponents == [
+ "3840 × 2160",
+ "23.976 fps",
+ "HEVC",
+ "HDR10",
+ "18.5 Mbps video",
+ "E-AC-3 5.1",
+ "48 kHz",
+ "640 kbps audio",
+ ])
+ #expect(facts.compactDisplayComponents == [
+ "3840 × 2160",
+ "HEVC",
+ "HDR10",
+ "E-AC-3 5.1",
+ ])
+ #expect(facts.diagnosticFacts.map { "\($0.label): \($0.value)" } == [
+ "Resolution: 3840 × 2160",
+ "Frame Rate: 23.976 fps",
+ "Video Codec: HEVC",
+ "Dynamic Range: HDR10",
+ "Video Data Rate: 18.5 Mbps",
+ "Audio Codec: E-AC-3",
+ "Channels: 5.1",
+ "Sample Rate: 48 kHz",
+ "Audio Data Rate: 640 kbps",
+ ])
+ #expect(Set(facts.diagnosticFacts.map(\.id)).count == facts.diagnosticFacts.count)
+ #expect(facts.videoDiagnosticFacts.map(\.kind) == [
+ .resolution,
+ .frameRate,
+ .videoCodec,
+ .dynamicRange,
+ .videoBitRate,
+ ])
+ #expect(facts.audioDiagnosticFacts.map(\.kind) == [
+ .audioCodec,
+ .channels,
+ .sampleRate,
+ .audioBitRate,
+ ])
+ }
+
+ @Test func reportsDeliveredAtmosLayoutWithoutInferringItFromChannelCount() throws {
+ let atmosDescription = try makeAudioDescription(
+ formatID: kAudioFormatEnhancedAC3,
+ channelCount: 12,
+ layoutTag: kAudioChannelLayoutTag_Atmos_7_1_4
+ )
+ let atmosFacts = try #require(PlexNativeMediaInspector.facts(
+ videoFormatDescription: nil,
+ audioFormatDescription: atmosDescription
+ ))
+
+ #expect(atmosFacts.audioLayout == .atmos7_1_4)
+ #expect(atmosFacts.displayComponents == ["E-AC-3 Atmos 7.1.4", "48 kHz"])
+
+ let unspecifiedDescription = try makeAudioDescription(
+ formatID: kAudioFormatEnhancedAC3,
+ channelCount: 12
+ )
+ let unspecifiedFacts = try #require(PlexNativeMediaInspector.facts(
+ videoFormatDescription: nil,
+ audioFormatDescription: unspecifiedDescription
+ ))
+
+ #expect(unspecifiedFacts.audioLayout == nil)
+ #expect(unspecifiedFacts.displayComponents == ["E-AC-3 12 ch", "48 kHz"])
+ }
+
+ @Test func invalidTrackRatesNeverBecomePlaybackFacts() {
+ #expect(PlexNativeMediaInspector.facts(
+ videoFormatDescription: nil,
+ audioFormatDescription: nil,
+ videoFrameRate: .nan,
+ videoBitRate: -.infinity,
+ audioBitRate: 0
+ ) == nil)
+ }
+
+ @Test func formatsFractionalAudioSampleRatesWithoutRoundingAwayFacts() throws {
+ let description = try makeAudioDescription(
+ formatID: kAudioFormatMPEG4AAC,
+ channelCount: 2,
+ sampleRate: 44_100
+ )
+ let facts = try #require(PlexNativeMediaInspector.facts(
+ videoFormatDescription: nil,
+ audioFormatDescription: description,
+ audioBitRate: 256_500
+ ))
+
+ #expect(facts.audioSampleRate == 44_100)
+ #expect(facts.displayComponents == [
+ "AAC Stereo",
+ "44.1 kHz",
+ "256.5 kbps audio",
+ ])
+ }
+
+ @Test func mapsOnlyAuthoritativeSurroundLayoutTags() {
+ #expect(
+ PlexNativeMediaInspector.audioLayout(for: kAudioChannelLayoutTag_MPEG_5_1_D)
+ == .surround5_1
+ )
+ #expect(
+ PlexNativeMediaInspector.audioLayout(for: kAudioChannelLayoutTag_EAC3_6_1_A)
+ == .surround6_1
+ )
+ #expect(
+ PlexNativeMediaInspector.audioLayout(for: kAudioChannelLayoutTag_DTS_7_1)
+ == .surround7_1
+ )
+ #expect(
+ PlexNativeMediaInspector.audioLayout(for: kAudioChannelLayoutTag_DiscreteInOrder | 6)
+ == nil
+ )
+ }
+
+ @Test func distinguishesDolbyVisionHLGAndExplicitSDR() {
+ #expect(PlexNativeMediaInspector.dynamicRange(
+ mediaSubtype: kCMVideoCodecType_DolbyVisionHEVC,
+ transferFunction: nil,
+ hasHDR10StaticMetadata: false
+ ) == .dolbyVision)
+ #expect(PlexNativeMediaInspector.dynamicRange(
+ mediaSubtype: kCMVideoCodecType_HEVC,
+ transferFunction: kCMFormatDescriptionTransferFunction_ITU_R_2100_HLG,
+ hasHDR10StaticMetadata: false
+ ) == .hlg)
+ #expect(PlexNativeMediaInspector.dynamicRange(
+ mediaSubtype: kCMVideoCodecType_H264,
+ transferFunction: kCMFormatDescriptionTransferFunction_ITU_R_709_2,
+ hasHDR10StaticMetadata: false
+ ) == .sdr)
+ }
+
+ @Test func doesNotCallPQHDR10WithoutStaticMetadata() {
+ #expect(PlexNativeMediaInspector.dynamicRange(
+ mediaSubtype: kCMVideoCodecType_HEVC,
+ transferFunction: kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ,
+ hasHDR10StaticMetadata: false
+ ) == .hdrPQ)
+ }
+
+ private func makeVideoDescription(
+ codec: CMVideoCodecType,
+ width: Int32,
+ height: Int32,
+ transferFunction: CFString,
+ includesHDR10StaticMetadata: Bool
+ ) throws -> CMVideoFormatDescription {
+ var extensions: [String: Any] = [
+ kCMFormatDescriptionExtension_TransferFunction as String: transferFunction,
+ ]
+ if includesHDR10StaticMetadata {
+ extensions[kCMFormatDescriptionExtension_ContentLightLevelInfo as String] = Data(
+ [0x03, 0xE8, 0x01, 0x90]
+ )
+ }
+
+ var description: CMVideoFormatDescription?
+ let status = CMVideoFormatDescriptionCreate(
+ allocator: kCFAllocatorDefault,
+ codecType: codec,
+ width: width,
+ height: height,
+ extensions: extensions as CFDictionary,
+ formatDescriptionOut: &description
+ )
+ #expect(status == noErr)
+ return try #require(description)
+ }
+
+ private func makeAudioDescription(
+ formatID: AudioFormatID,
+ channelCount: UInt32,
+ layoutTag: AudioChannelLayoutTag? = nil,
+ sampleRate: Double = 48_000
+ ) throws -> CMAudioFormatDescription {
+ var streamDescription = AudioStreamBasicDescription(
+ mSampleRate: sampleRate,
+ mFormatID: formatID,
+ mFormatFlags: 0,
+ mBytesPerPacket: 0,
+ mFramesPerPacket: 1_536,
+ mBytesPerFrame: 0,
+ mChannelsPerFrame: channelCount,
+ mBitsPerChannel: 0,
+ mReserved: 0
+ )
+ var channelLayout = AudioChannelLayout()
+ channelLayout.mChannelLayoutTag = layoutTag ?? kAudioChannelLayoutTag_Unknown
+ channelLayout.mChannelBitmap = AudioChannelBitmap(rawValue: 0)
+ channelLayout.mNumberChannelDescriptions = 0
+ var description: CMAudioFormatDescription?
+ let status = withUnsafePointer(to: &channelLayout) { layoutPointer in
+ CMAudioFormatDescriptionCreate(
+ allocator: kCFAllocatorDefault,
+ asbd: &streamDescription,
+ layoutSize: layoutTag == nil ? 0 : MemoryLayout.size,
+ layout: layoutTag == nil ? nil : layoutPointer,
+ magicCookieSize: 0,
+ magicCookie: nil,
+ extensions: nil,
+ formatDescriptionOut: &description
+ )
+ }
+ #expect(status == noErr)
+ return try #require(description)
+ }
+}
diff --git a/PlexBarTests/PlexNativeSkippingConfigurationTests.swift b/PlexBarTests/PlexNativeSkippingConfigurationTests.swift
new file mode 100644
index 0000000..d00e755
--- /dev/null
+++ b/PlexBarTests/PlexNativeSkippingConfigurationTests.swift
@@ -0,0 +1,61 @@
+import Testing
+@testable import PlexBar
+
+struct PlexNativeSkippingConfigurationTests {
+ @Test
+ func videoKeepsTimeSkippingWhenTheQueueHasAdjacentItems() {
+ let configuration = PlexNativeSkippingConfiguration(
+ mediaKind: .video,
+ canMovePrevious: true,
+ canMoveNext: true,
+ controlsEnabled: true
+ )
+
+ #expect(configuration.mode == .time)
+ #expect(configuration.isBackwardEnabled)
+ #expect(configuration.isForwardEnabled)
+ }
+
+ @Test
+ func musicUsesOnlyAvailableQueueDirections() {
+ let configuration = PlexNativeSkippingConfiguration(
+ mediaKind: .music,
+ canMovePrevious: false,
+ canMoveNext: true,
+ controlsEnabled: true
+ )
+
+ #expect(configuration.mode == .item)
+ #expect(!configuration.isBackwardEnabled)
+ #expect(configuration.isForwardEnabled)
+ }
+
+ @Test
+ func musicWithoutAdjacentItemsKeepsTimeSkipping() {
+ let configuration = PlexNativeSkippingConfiguration(
+ mediaKind: .music,
+ canMovePrevious: false,
+ canMoveNext: false,
+ controlsEnabled: true
+ )
+
+ #expect(configuration.mode == .time)
+ #expect(configuration.isBackwardEnabled)
+ #expect(configuration.isForwardEnabled)
+ }
+
+ @Test
+ func busyPlaybackDisablesEverySkippingDirection() {
+ for mediaKind in [PlexPlaybackMediaKind.video, .music] {
+ let configuration = PlexNativeSkippingConfiguration(
+ mediaKind: mediaKind,
+ canMovePrevious: true,
+ canMoveNext: true,
+ controlsEnabled: false
+ )
+
+ #expect(!configuration.isBackwardEnabled)
+ #expect(!configuration.isForwardEnabled)
+ }
+ }
+}
diff --git a/PlexBarTests/PlexNowPlayingMetadataTests.swift b/PlexBarTests/PlexNowPlayingMetadataTests.swift
new file mode 100644
index 0000000..dceaf0e
--- /dev/null
+++ b/PlexBarTests/PlexNowPlayingMetadataTests.swift
@@ -0,0 +1,557 @@
+import PlexModels
+import AppKit
+import CoreGraphics
+import Foundation
+import MediaPlayer
+import Testing
+@testable import PlexBar
+
+struct PlexNowPlayingMetadataTests {
+ @Test func episodePublishesItsPlexHierarchyAndPlaybackState() throws {
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "episode-42",
+ "title": "The We We Are",
+ "type": "episode",
+ "grandparentRatingKey": "show-7",
+ "parentRatingKey": "season-1",
+ "grandparentTitle": "Severance",
+ "parentTitle": "Season 1",
+ "Genre": [{"tag":"Drama"},{"tag":"Science Fiction"}]
+ }
+ """#)
+
+ let metadata = PlexNowPlayingMetadata(
+ item: item,
+ duration: 2_400,
+ elapsedTime: 125.5,
+ playbackRate: 1.5,
+ defaultPlaybackRate: 1.5,
+ serverIdentifier: "server-a",
+ queuePosition: 3,
+ queueCount: 10
+ )
+
+ #expect(metadata.title == "The We We Are")
+ #expect(metadata.artist == "Severance")
+ #expect(metadata.albumTitle == "Season 1")
+ #expect(metadata.mediaKind == .television)
+ #expect(metadata.duration == 2_400)
+ #expect(metadata.elapsedTime == 125.5)
+ #expect(metadata.playbackRate == 1.5)
+ #expect(metadata.defaultPlaybackRate == 1.5)
+ #expect(metadata.genre == "Drama, Science Fiction")
+ #expect(metadata.externalContentIdentifier == "plex:8:server-a:10:episode-42")
+ #expect(metadata.collectionIdentifier == "plex:8:server-a:6:show-7")
+ #expect(metadata.queueIndex == 2)
+ #expect(metadata.queueCount == 10)
+
+ let info = metadata.nowPlayingInfo
+ #expect(info[MPMediaItemPropertyTitle] as? String == "The We We Are")
+ #expect(info[MPMediaItemPropertyArtist] as? String == "Severance")
+ #expect(info[MPMediaItemPropertyAlbumTitle] as? String == "Season 1")
+ #expect((info[MPMediaItemPropertyPlaybackDuration] as? NSNumber)?.doubleValue == 2_400)
+ #expect((info[MPNowPlayingInfoPropertyElapsedPlaybackTime] as? NSNumber)?.doubleValue == 125.5)
+ #expect((info[MPNowPlayingInfoPropertyPlaybackRate] as? NSNumber)?.doubleValue == 1.5)
+ #expect((info[MPNowPlayingInfoPropertyDefaultPlaybackRate] as? NSNumber)?.doubleValue == 1.5)
+ #expect(info[MPMediaItemPropertyGenre] as? String == "Drama, Science Fiction")
+ #expect(
+ info[MPNowPlayingInfoPropertyExternalContentIdentifier] as? String
+ == "plex:8:server-a:10:episode-42"
+ )
+ #expect(
+ info[MPNowPlayingInfoCollectionIdentifier] as? String
+ == "plex:8:server-a:6:show-7"
+ )
+ #expect((info[MPNowPlayingInfoPropertyPlaybackQueueIndex] as? NSNumber)?.intValue == 2)
+ #expect((info[MPNowPlayingInfoPropertyPlaybackQueueCount] as? NSNumber)?.intValue == 10)
+ #expect(
+ (info[MPNowPlayingInfoPropertyMediaType] as? NSNumber)?.uintValue
+ == MPNowPlayingInfoMediaType.video.rawValue
+ )
+ #expect((info[MPMediaItemPropertyMediaType] as? NSNumber)?.uintValue == MPMediaType.tvShow.rawValue)
+ }
+
+ @Test func serverCreditsMarkerPublishesTheSystemCreditsStartTime() throws {
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "movie-42",
+ "title": "Movie",
+ "type": "movie",
+ "Marker": [
+ {"type":"intro","startTimeOffset":10000,"endTimeOffset":20000},
+ {"type":"credits","startTimeOffset":2300000,"endTimeOffset":2400000}
+ ]
+ }
+ """#)
+
+ let metadata = PlexNowPlayingMetadata(
+ item: item,
+ duration: 2_400,
+ elapsedTime: 0,
+ playbackRate: 1
+ )
+
+ #expect(metadata.creditsStartTime == 2_300)
+ #expect(
+ (metadata.nowPlayingInfo[MPNowPlayingInfoPropertyCreditsStartTime] as? NSNumber)?.doubleValue
+ == 2_300
+ )
+ }
+
+ @Test func artworkRequestsPreferHierarchyPostersWithoutLeakingTheTokenIntoURLs() throws {
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "episode-42",
+ "title": "The We We Are",
+ "type": "episode",
+ "thumb": "/library/metadata/42/thumb",
+ "parentThumb": "/library/metadata/season-1/thumb",
+ "grandparentThumb": "/library/metadata/show-1/thumb",
+ "art": "/library/metadata/show-1/art"
+ }
+ """#)
+ let serverURL = try #require(URL(string: "https://plex.example.test:32400"))
+ let request = try #require(PlexNowPlayingArtworkRequest(
+ item: item,
+ serverURL: serverURL,
+ token: "secret-pms-token",
+ clientContext: PlexClientContext(clientIdentifier: "now-playing-artwork-test")
+ ))
+
+ #expect(request.candidateURLs.map(\.path) == [
+ "/library/metadata/show-1/thumb",
+ "/library/metadata/season-1/thumb",
+ ])
+ #expect(request.candidateURLs.allSatisfy {
+ !$0.absoluteString.contains("secret-pms-token")
+ && URLComponents(url: $0, resolvingAgainstBaseURL: false)?.queryItems?.contains {
+ $0.name.caseInsensitiveCompare("X-Plex-Token") == .orderedSame
+ } != true
+ })
+ #expect(request.token == "secret-pms-token")
+ #expect(PlexNowPlayingArtworkRequest.maximumPixelSize == 1_200)
+ }
+
+ @Test func nowPlayingArtworkUsesTheModernSizeRequestContract() throws {
+ let image = try #require(testCGImage(width: 600, height: 900))
+ let artwork = PlexNowPlayingArtworkFactory.make(from: image)
+
+ #expect(artwork.bounds.size == CGSize(width: 600, height: 900))
+ #expect(artwork.image(at: CGSize(width: 200, height: 300))?.size == CGSize(
+ width: 200,
+ height: 300
+ ))
+ #expect(artwork.image(at: CGSize(width: 1_200, height: 1_800))?.size == CGSize(
+ width: 600,
+ height: 900
+ ))
+
+ let item = try decodeItem(#"""
+ {"ratingKey":"movie-1","title":"Movie","type":"movie"}
+ """#)
+ let metadata = PlexNowPlayingMetadata(
+ item: item,
+ duration: 90,
+ elapsedTime: 0,
+ playbackRate: 0
+ )
+ #expect(metadata.nowPlayingInfo[MPMediaItemPropertyArtwork] == nil)
+ #expect(metadata.nowPlayingInfo(artwork: artwork)[MPMediaItemPropertyArtwork] as? MPMediaItemArtwork === artwork)
+ }
+
+ @MainActor
+ @Test func artworkLoaderRejectsAResultSupersededByTheNextItem() async throws {
+ let firstImage = try #require(testCGImage(width: 300, height: 450))
+ let secondImage = try #require(testCGImage(width: 400, height: 600))
+ let gate = NowPlayingArtworkLoadGate(
+ delayedRatingKey: "first",
+ delayedImage: firstImage,
+ immediateImage: secondImage
+ )
+ let loader = PlexNowPlayingArtworkLoader(fetchImage: { request in
+ await gate.fetch(request)
+ })
+ let serverURL = try #require(URL(string: "https://plex.example.test:32400"))
+ let context = PlexClientContext(clientIdentifier: "now-playing-supersession-test")
+ let firstRequest = try #require(PlexNowPlayingArtworkRequest(
+ item: decodeItem(#"""
+ {"ratingKey":"first","title":"First","type":"movie","thumb":"/first"}
+ """#),
+ serverURL: serverURL,
+ token: "",
+ clientContext: context
+ ))
+ let secondRequest = try #require(PlexNowPlayingArtworkRequest(
+ item: decodeItem(#"""
+ {"ratingKey":"second","title":"Second","type":"movie","thumb":"/second"}
+ """#),
+ serverURL: serverURL,
+ token: "",
+ clientContext: context
+ ))
+
+ let firstLoad = Task {
+ await loader.load(firstRequest)
+ }
+ await gate.waitUntilDelayedLoadStarts()
+ let resolvedSecond = await loader.load(secondRequest)
+ await gate.finishDelayedLoad()
+ let resolvedFirst = await firstLoad.value
+
+ #expect(resolvedSecond?.width == secondImage.width)
+ #expect(resolvedFirst == nil)
+ }
+
+ @Test func trackUsesAudioMediaTypeAndClampsInvalidTiming() throws {
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "track-9",
+ "title": "Kitchen Confidential",
+ "type": "track",
+ "grandparentTitle": "Anthony Bourdain",
+ "parentTitle": "Kitchen Confidential",
+ "parentRatingKey": "album-5",
+ "index": "9",
+ "parentIndex": "2",
+ "Genre": [{"tag":"Audiobook"}]
+ }
+ """#)
+
+ let metadata = PlexNowPlayingMetadata(
+ item: item,
+ duration: -.infinity,
+ elapsedTime: -.infinity,
+ playbackRate: -.infinity,
+ defaultPlaybackRate: .infinity,
+ serverIdentifier: "server-a"
+ )
+
+ #expect(metadata.mediaKind == .audio)
+ #expect(metadata.duration == nil)
+ #expect(metadata.elapsedTime == 0)
+ #expect(metadata.playbackRate == 0)
+ #expect(metadata.defaultPlaybackRate == 1)
+ #expect(metadata.albumTrackNumber == 9)
+ #expect(metadata.discNumber == 2)
+ #expect(metadata.genre == "Audiobook")
+ #expect(metadata.collectionIdentifier == "plex:8:server-a:7:album-5")
+ #expect(metadata.nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] == nil)
+ #expect(
+ (metadata.nowPlayingInfo[MPMediaItemPropertyAlbumTrackNumber] as? NSNumber)?.intValue == 9
+ )
+ #expect(
+ (metadata.nowPlayingInfo[MPMediaItemPropertyDiscNumber] as? NSNumber)?.intValue == 2
+ )
+ #expect(
+ (metadata.nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] as? NSNumber)?.uintValue
+ == MPNowPlayingInfoMediaType.audio.rawValue
+ )
+ #expect(
+ (metadata.nowPlayingInfo[MPMediaItemPropertyMediaType] as? NSNumber)?.uintValue
+ == MPMediaType.anyAudio.rawValue
+ )
+ }
+
+ @Test func identifiersAndQueueFactsAreOmittedWhenTheirScopeIsNotAuthoritative() throws {
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "episode-42",
+ "title": "The We We Are",
+ "type": "episode",
+ "grandparentRatingKey": "show-7",
+ "index": "4",
+ "parentIndex": "1"
+ }
+ """#)
+
+ let metadata = PlexNowPlayingMetadata(
+ item: item,
+ duration: 2_400,
+ elapsedTime: 0,
+ playbackRate: 0,
+ queuePosition: 11,
+ queueCount: 10
+ )
+
+ #expect(metadata.externalContentIdentifier == nil)
+ #expect(metadata.collectionIdentifier == nil)
+ #expect(metadata.queueIndex == nil)
+ #expect(metadata.queueCount == nil)
+ #expect(metadata.albumTrackNumber == nil)
+ #expect(metadata.discNumber == nil)
+ #expect(metadata.nowPlayingInfo[MPNowPlayingInfoPropertyExternalContentIdentifier] == nil)
+ #expect(metadata.nowPlayingInfo[MPNowPlayingInfoCollectionIdentifier] == nil)
+ #expect(metadata.nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackQueueIndex] == nil)
+ #expect(metadata.nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackQueueCount] == nil)
+ #expect(metadata.nowPlayingInfo[MPMediaItemPropertyAlbumTrackNumber] == nil)
+ #expect(metadata.nowPlayingInfo[MPMediaItemPropertyDiscNumber] == nil)
+ }
+
+ @Test func publicationFingerprintIgnoresClockDriftButDetectsControlChanges() throws {
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "episode-42",
+ "title": "The We We Are",
+ "type": "episode",
+ "grandparentRatingKey": "show-7",
+ "grandparentTitle": "Severance"
+ }
+ """#)
+ let original = PlexNowPlayingMetadata(
+ item: item,
+ duration: 2_400,
+ elapsedTime: 100,
+ playbackRate: 1,
+ serverIdentifier: "server-a",
+ queuePosition: 3,
+ queueCount: 10
+ )
+ let ordinaryClockDrift = PlexNowPlayingMetadata(
+ item: item,
+ duration: 2_400,
+ elapsedTime: 101,
+ playbackRate: 0,
+ serverIdentifier: "server-a",
+ queuePosition: 3,
+ queueCount: 10
+ )
+ let changedQueue = PlexNowPlayingMetadata(
+ item: item,
+ duration: 2_400,
+ elapsedTime: 101,
+ playbackRate: 0,
+ serverIdentifier: "server-a",
+ queuePosition: 4,
+ queueCount: 12
+ )
+ let changedSelectedSpeed = PlexNowPlayingMetadata(
+ item: item,
+ duration: 2_400,
+ elapsedTime: 101,
+ playbackRate: 0,
+ defaultPlaybackRate: 1.5,
+ serverIdentifier: "server-a",
+ queuePosition: 3,
+ queueCount: 10
+ )
+
+ #expect(original != ordinaryClockDrift)
+ #expect(original.publicationFingerprint == ordinaryClockDrift.publicationFingerprint)
+ #expect(original.publicationFingerprint != changedQueue.publicationFingerprint)
+ #expect(original.publicationFingerprint != changedSelectedSpeed.publicationFingerprint)
+ }
+
+ @Test func serverFallbackTracksPublishNativeNowPlayingLanguageGroups() throws {
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "movie-1",
+ "title": "Movie",
+ "type": "movie",
+ "Media": [{
+ "Part": [{
+ "id": "700",
+ "Stream": [
+ {"id":"21","streamType":"2","displayTitle":"English 5.1","languageCode":"eng","selected":"1"},
+ {"id":"22","streamType":"2","displayTitle":"French Audio Description","languageCode":"fra","visualImpaired":"1"},
+ {"id":"31","streamType":"3","displayTitle":"English SDH","languageCode":"eng","selected":"1","hearingImpaired":"1"},
+ {"id":"32","streamType":"3","displayTitle":"Spanish Forced","languageCode":"spa","forced":"1"}
+ ]
+ }]
+ }]
+ }
+ """#)
+ let selection = PlexPlaybackMediaSelection(
+ item: item,
+ source: PlexPlaybackSource(mediaIndex: 0, partIndex: 0)
+ )
+ let languageOptions = PlexNowPlayingLanguageOptions(
+ selection: selection,
+ nativeAvailability: PlexNativeMediaSelectionAvailability(
+ audioOptionCount: 0,
+ subtitleOptionCount: 0
+ )
+ )
+
+ #expect(languageOptions.groups.count == 2)
+ #expect(languageOptions.currentOptions.map(\.identifier) == [
+ "plex-audio-stream:21",
+ "plex-subtitle-stream:31",
+ ])
+ #expect(languageOptions.hasSelectedSubtitle)
+
+ let audioGroup = languageOptions.groups[0]
+ #expect(!audioGroup.allowEmptySelection)
+ #expect(audioGroup.defaultLanguageOption?.identifier == "plex-audio-stream:21")
+ #expect(audioGroup.languageOptions.map(\.identifier) == [
+ "plex-audio-stream:21",
+ "plex-audio-stream:22",
+ ])
+ #expect(audioGroup.languageOptions.map(\.languageTag) == ["eng", "fra"])
+ #expect(audioGroup.languageOptions[0].languageOptionCharacteristics?.contains(
+ MPLanguageOptionCharacteristicIsMainProgramContent
+ ) == true)
+ #expect(audioGroup.languageOptions[1].languageOptionCharacteristics?.contains(
+ MPLanguageOptionCharacteristicDescribesVideo
+ ) == true)
+
+ let subtitleGroup = languageOptions.groups[1]
+ #expect(subtitleGroup.allowEmptySelection)
+ #expect(subtitleGroup.defaultLanguageOption?.identifier == "plex-subtitle-stream:31")
+ #expect(subtitleGroup.languageOptions[0].languageOptionCharacteristics?.contains(
+ MPLanguageOptionCharacteristicDescribesMusicAndSound
+ ) == true)
+ #expect(subtitleGroup.languageOptions[1].languageOptionCharacteristics?.contains(
+ MPLanguageOptionCharacteristicContainsOnlyForcedSubtitles
+ ) == true)
+
+ let metadata = PlexNowPlayingMetadata(
+ item: item,
+ duration: 90,
+ elapsedTime: 0,
+ playbackRate: 0
+ )
+ let info = metadata.nowPlayingInfo(artwork: nil, languageOptions: languageOptions)
+ let publishedGroups = info[MPNowPlayingInfoPropertyAvailableLanguageOptions]
+ as? [MPNowPlayingInfoLanguageOptionGroup]
+ let publishedCurrent = info[MPNowPlayingInfoPropertyCurrentLanguageOptions]
+ as? [MPNowPlayingInfoLanguageOption]
+ #expect(publishedGroups?.count == 2)
+ #expect(publishedCurrent?.map(\.identifier) == languageOptions.currentOptions.map(\.identifier))
+
+ #expect(PlexNowPlayingLanguageSelection(
+ languageOption: audioGroup.languageOptions[1]
+ ) == .audio(streamID: 22))
+ #expect(PlexNowPlayingLanguageSelection(
+ languageOption: subtitleGroup.languageOptions[1]
+ ) == .subtitle(streamID: 32))
+ }
+
+ @Test func assetNativeGroupsRemainExclusivelyOwnedByAVKit() throws {
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "movie-1",
+ "title": "Movie",
+ "Media": [{
+ "Part": [{
+ "id": "700",
+ "Stream": [
+ {"id":"21","streamType":"2","languageCode":"eng","selected":"1"},
+ {"id":"22","streamType":"2","languageCode":"fra"},
+ {"id":"31","streamType":"3","languageCode":"eng"}
+ ]
+ }]
+ }]
+ }
+ """#)
+ let selection = PlexPlaybackMediaSelection(
+ item: item,
+ source: PlexPlaybackSource(mediaIndex: 0, partIndex: 0)
+ )
+
+ let allNative = PlexNowPlayingLanguageOptions(
+ selection: selection,
+ nativeAvailability: PlexNativeMediaSelectionAvailability(
+ audioOptionCount: 2,
+ subtitleOptionCount: 1
+ )
+ )
+ #expect(!allNative.hasAvailableOptions)
+ #expect(allNative.currentOptions.isEmpty)
+
+ let audioNative = PlexNowPlayingLanguageOptions(
+ selection: selection,
+ nativeAvailability: PlexNativeMediaSelectionAvailability(
+ audioOptionCount: 2,
+ subtitleOptionCount: 0
+ )
+ )
+ #expect(audioNative.groups.count == 1)
+ #expect(audioNative.groups[0].languageOptions.allSatisfy {
+ $0.languageOptionType == .legible
+ })
+
+ let partiallyNative = PlexNowPlayingLanguageOptions(
+ selection: selection,
+ nativeAvailability: PlexNativeMediaSelectionAvailability(
+ audioOptionCount: 1,
+ subtitleOptionCount: 0
+ )
+ )
+ #expect(partiallyNative.groups.count == 2)
+ #expect(partiallyNative.groups[0].languageOptions.allSatisfy {
+ $0.languageOptionType == .audible
+ })
+
+ let inspectionPending = PlexNowPlayingLanguageOptions(
+ selection: selection,
+ nativeAvailability: nil
+ )
+ #expect(!inspectionPending.hasAvailableOptions)
+ }
+
+ private func decodeItem(_ json: String) throws -> PlexMediaItem {
+ try JSONDecoder().decode(PlexMediaItem.self, from: Data(json.utf8))
+ }
+
+ private func testCGImage(width: Int, height: Int) -> CGImage? {
+ guard let context = CGContext(
+ data: nil,
+ width: width,
+ height: height,
+ bitsPerComponent: 8,
+ bytesPerRow: width * 4,
+ space: CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ ) else {
+ return nil
+ }
+ context.setFillColor(NSColor.systemBlue.cgColor)
+ context.fill(CGRect(x: 0, y: 0, width: width, height: height))
+ return context.makeImage()
+ }
+}
+
+private actor NowPlayingArtworkLoadGate {
+ private let delayedRatingKey: String
+ private let delayedImage: PlexCGImageBox
+ private let immediateImage: PlexCGImageBox
+ private var delayedLoadStarted = false
+ private var startWaiters: [CheckedContinuation] = []
+ private var delayedLoadContinuation: CheckedContinuation?
+
+ init(delayedRatingKey: String, delayedImage: CGImage, immediateImage: CGImage) {
+ self.delayedRatingKey = delayedRatingKey
+ self.delayedImage = PlexCGImageBox(delayedImage)
+ self.immediateImage = PlexCGImageBox(immediateImage)
+ }
+
+ func fetch(_ request: PlexNowPlayingArtworkRequest) async -> PlexCGImageBox? {
+ guard request.candidateURLs.first?.path == "/\(delayedRatingKey)" else {
+ return immediateImage
+ }
+
+ delayedLoadStarted = true
+ let waiters = startWaiters
+ startWaiters.removeAll()
+ waiters.forEach { $0.resume() }
+ await withCheckedContinuation { continuation in
+ delayedLoadContinuation = continuation
+ }
+ return delayedImage
+ }
+
+ func waitUntilDelayedLoadStarts() async {
+ guard !delayedLoadStarted else {
+ return
+ }
+ await withCheckedContinuation { continuation in
+ startWaiters.append(continuation)
+ }
+ }
+
+ func finishDelayedLoad() {
+ delayedLoadContinuation?.resume()
+ delayedLoadContinuation = nil
+ }
+}
diff --git a/PlexBarTests/PlexPeopleTests.swift b/PlexBarTests/PlexPeopleTests.swift
new file mode 100644
index 0000000..221369e
--- /dev/null
+++ b/PlexBarTests/PlexPeopleTests.swift
@@ -0,0 +1,316 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+@Suite(.serialized)
+struct PlexPeopleTests {
+ @Test func decodesPublishedPeopleFieldsAndSeparatesCrewFromOrderedCast() throws {
+ let item = try JSONDecoder().decode(PlexMediaItem.self, from: Data(#"""
+ {
+ "ratingKey": "42",
+ "title": "Movie",
+ "type": "movie",
+ "Director": [{
+ "id": 10,
+ "tag": "Director Person",
+ "tagKey": "director-key",
+ "tagType": 4,
+ "filter": "director=10",
+ "thumb": "https://metadata-static.plex.tv/director.jpg"
+ }],
+ "Writer": [
+ { "id": 98, "tag": "Director Person", "tagKey": "director-key" },
+ { "id": 11, "tag": "Writer Person" }
+ ],
+ "Producer": [{ "id": 12, "tag": "Producer Person" }],
+ "Role": [
+ { "id": 21, "tag": "Second Billing", "role": "Friend", "order": 2 },
+ { "id": 20, "tag": "First Billing", "role": "Lead", "order": 1 },
+ {
+ "id": 99,
+ "tag": "Director Person",
+ "tagKey": "director-key",
+ "role": "Alex",
+ "order": 3
+ }
+ ]
+ }
+ """#.utf8))
+
+ #expect(item.directors[0].id == 10)
+ #expect(item.directors[0].tagKey == "director-key")
+ #expect(item.directors[0].tagType == 4)
+ #expect(item.directors[0].filter == "director=10")
+ #expect(item.directors[0].thumb == "https://metadata-static.plex.tv/director.jpg")
+ #expect(item.producers.map(\.tag) == ["Producer Person"])
+
+ let presentation = PlexCastAndCrewPresentation(item: item)
+ #expect(presentation.crew.map(\.name) == [
+ "Director Person", "Writer Person", "Producer Person"
+ ])
+ #expect(presentation.crew.map(\.subtitle) == [
+ "Director · Writer", "Writer", "Producer"
+ ])
+ #expect(presentation.cast.map(\.name) == [
+ "First Billing", "Second Billing", "Director Person"
+ ])
+ #expect(presentation.cast.map(\.subtitle) == [
+ "Lead", "Friend", "Alex"
+ ])
+ #expect(presentation.crew.compactMap(\.route).map(\.identifier) == [
+ "director-key", "11", "12"
+ ])
+ #expect(presentation.cast.compactMap(\.route).map(\.identifier) == [
+ "20", "21", "director-key"
+ ])
+ #expect(presentation.cast.compactMap(\.route).map(\.name) == [
+ "First Billing", "Second Billing", "Director Person"
+ ])
+ }
+
+ @Test func episodeWithoutRolesLoadsCastFromItsExactSeriesIdentity() async throws {
+ let episode = try JSONDecoder().decode(PlexMediaItem.self, from: Data(#"""
+ {
+ "ratingKey": "episode-17",
+ "type": "episode",
+ "title": "Are We Really Doing This?",
+ "grandparentRatingKey": "show-90",
+ "Producer": [{ "id": 8, "tag": "Episode Producer" }]
+ }
+ """#.utf8))
+ let capture = RequestCapture()
+ let session = makePeopleMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, Data(#"""
+ {
+ "MediaContainer": {
+ "Metadata": [{
+ "ratingKey": "show-90",
+ "type": "show",
+ "title": "90 Day Fiancé",
+ "Role": [
+ { "id": 22, "tag": "Series Lead", "role": "Self", "order": 1 },
+ { "id": 23, "tag": "Series Regular", "role": "Self", "order": 2 }
+ ]
+ }]
+ }
+ }
+ """#.utf8))
+ }
+ let configuration = PlexConnectionConfiguration(
+ serverURL: try #require(URL(string: "https://plex.local:32400")),
+ token: "server-token",
+ clientContext: PlexClientContext(clientIdentifier: "test-client")
+ )
+
+ let cast = try await PlexAPIClient(session: session).fetchEpisodeSeriesCast(
+ for: episode,
+ using: configuration
+ )
+ let presentation = PlexCastAndCrewPresentation(
+ item: episode,
+ episodeSeriesCast: cast
+ )
+
+ #expect(capture.requests.count == 1)
+ #expect(capture.request?.url?.path == "/library/metadata/show-90")
+ #expect(capture.request?.url?.query == "includeOptionalElements=Chapter,Image,Marker,Rating&includeGuids=1")
+ #expect(capture.request?.value(forHTTPHeaderField: "X-Plex-Token") == "server-token")
+ #expect(presentation.cast.map(\.name) == ["Series Lead", "Series Regular"])
+ #expect(presentation.crew.map(\.name) == ["Episode Producer"])
+ }
+
+ @Test func aPersonsDestinationDoesNotDependOnTheOriginatingCredit() throws {
+ let lead = try JSONDecoder().decode(PlexMediaItem.self, from: Data(#"""
+ {
+ "ratingKey": "1",
+ "title": "First Movie",
+ "type": "movie",
+ "Role": [{ "id": 20, "tag": "Same Person", "role": "Lead" }]
+ }
+ """#.utf8))
+ let guest = try JSONDecoder().decode(PlexMediaItem.self, from: Data(#"""
+ {
+ "ratingKey": "2",
+ "title": "Second Movie",
+ "type": "movie",
+ "Role": [{ "id": 20, "tag": "Same Person", "role": "Guest" }]
+ }
+ """#.utf8))
+
+ let leadCredit = try #require(PlexCastAndCrewPresentation(item: lead).cast.first)
+ let guestCredit = try #require(PlexCastAndCrewPresentation(item: guest).cast.first)
+
+ #expect(leadCredit.subtitle == "Lead")
+ #expect(guestCredit.subtitle == "Guest")
+ #expect(leadCredit.route == guestCredit.route)
+ }
+
+ @Test func directEpisodeCastWinsWithoutRequestingSeriesMetadata() async throws {
+ let episode = try JSONDecoder().decode(PlexMediaItem.self, from: Data(#"""
+ {
+ "ratingKey": "episode-17",
+ "type": "episode",
+ "title": "Episode",
+ "grandparentRatingKey": "show-90",
+ "Role": [{ "id": 31, "tag": "Guest Star", "role": "Guest" }]
+ }
+ """#.utf8))
+ let session = makePeopleMockSession { _ in
+ Issue.record("Series metadata must not be requested when the episode has direct cast.")
+ throw PlexAPIError.invalidResponse
+ }
+ let configuration = PlexConnectionConfiguration(
+ serverURL: try #require(URL(string: "https://plex.local:32400")),
+ token: "server-token",
+ clientContext: PlexClientContext(clientIdentifier: "test-client")
+ )
+
+ let inherited = try await PlexAPIClient(session: session).fetchEpisodeSeriesCast(
+ for: episode,
+ using: configuration
+ )
+ let presentation = PlexCastAndCrewPresentation(
+ item: episode,
+ episodeSeriesCast: [
+ PlexTag(
+ id: 99,
+ tag: "Wrong Series Cast",
+ tagKey: nil,
+ tagType: nil,
+ filter: nil,
+ role: "Self",
+ thumb: nil,
+ order: nil
+ ),
+ ]
+ )
+
+ #expect(inherited.isEmpty)
+ #expect(presentation.cast.map(\.name) == ["Guest Star"])
+ }
+
+ @Test func personEndpointsUseExactAuthenticatedPMSPaths() async throws {
+ let capture = RequestCapture()
+ let session = makePeopleMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ if request.url?.path.hasSuffix("/media") == true {
+ return (response, Data(#"{"MediaContainer":{"Metadata":[{"ratingKey":"50","title":"Film"}]}}"#.utf8))
+ }
+ return (response, Data(#"{"MediaContainer":{"Directory":[{"id":53374,"tag":"Jay Chandrasekhar","tagKey":"person-key"}]}}"#.utf8))
+ }
+ let configuration = PlexConnectionConfiguration(
+ serverURL: try #require(URL(string: "https://plex.local:32400")),
+ token: "server-token",
+ clientContext: PlexClientContext(clientIdentifier: "test-client")
+ )
+ let client = PlexAPIClient(session: session)
+
+ let person = try await client.fetchPerson(identifier: "person-key", using: configuration)
+ let media = try await client.fetchPersonMedia(identifier: "person-key", using: configuration)
+
+ #expect(person.id == 53374)
+ #expect(media.map(\.title) == ["Film"])
+ #expect(capture.requests.map { $0.url?.path } == [
+ "/library/people/person-key",
+ "/library/people/person-key/media",
+ ])
+ #expect(capture.requests.allSatisfy {
+ $0.value(forHTTPHeaderField: "X-Plex-Token") == "server-token"
+ })
+ }
+
+ @Test func portraitRequestsNeverSendServerTokensToExternalHosts() throws {
+ let serverURL = try #require(URL(string: "https://plex.local:32400"))
+
+ let local = try #require(PlexImageRequest(
+ path: "/library/metadata/10/thumb",
+ serverURL: serverURL,
+ serverToken: "secret"
+ ))
+ #expect(local.url.absoluteString == "https://plex.local:32400/library/metadata/10/thumb")
+ #expect(local.token == "secret")
+
+ let external = try #require(PlexImageRequest(
+ path: "https://metadata-static.plex.tv/person.jpg",
+ serverURL: serverURL,
+ serverToken: "secret"
+ ))
+ #expect(external.url.absoluteString == "https://metadata-static.plex.tv/person.jpg")
+ #expect(external.token.isEmpty)
+ }
+
+ @Test @MainActor func avatarRequestsScopeCredentialsToTrustedOrigins() throws {
+ let serverURL = try #require(URL(string: "https://plex.local:32400"))
+
+ let serverAvatar = try #require(PlexAvatarView.resolveRequest(
+ thumb: "https://plex.local:32400/accounts/7/avatar",
+ serverURL: serverURL,
+ serverToken: "server-secret",
+ userToken: "user-secret"
+ ))
+ #expect(serverAvatar.token == "server-secret")
+
+ let plexAvatar = try #require(PlexAvatarView.resolveRequest(
+ thumb: "https://plex.tv/users/7/avatar",
+ serverURL: serverURL,
+ serverToken: "server-secret",
+ userToken: "user-secret"
+ ))
+ #expect(plexAvatar.token == "user-secret")
+
+ let externalAvatar = try #require(PlexAvatarView.resolveRequest(
+ thumb: "https://example.com/users/7/avatar",
+ serverURL: serverURL,
+ serverToken: "server-secret",
+ userToken: "user-secret"
+ ))
+ #expect(externalAvatar.token.isEmpty)
+ }
+}
+
+private func makePeopleMockSession(
+ handler: @escaping @Sendable (URLRequest) throws -> (HTTPURLResponse, Data)
+) -> URLSession {
+ PeopleMockURLProtocol.requestHandler = handler
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.protocolClasses = [PeopleMockURLProtocol.self]
+ return URLSession(configuration: configuration)
+}
+
+private final class PeopleMockURLProtocol: URLProtocol, @unchecked Sendable {
+ nonisolated(unsafe) static var requestHandler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))?
+
+ override static func canInit(with request: URLRequest) -> Bool { true }
+ override static func canonicalRequest(for request: URLRequest) -> URLRequest { request }
+
+ override func startLoading() {
+ guard let handler = Self.requestHandler else {
+ client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
+ return
+ }
+ do {
+ let (response, data) = try handler(request)
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: data)
+ client?.urlProtocolDidFinishLoading(self)
+ } catch {
+ client?.urlProtocol(self, didFailWithError: error)
+ }
+ }
+
+ override func stopLoading() {}
+}
diff --git a/PlexBarTests/PlexPerformanceMetricsTests.swift b/PlexBarTests/PlexPerformanceMetricsTests.swift
new file mode 100644
index 0000000..d3e9279
--- /dev/null
+++ b/PlexBarTests/PlexPerformanceMetricsTests.swift
@@ -0,0 +1,203 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+@Suite(.serialized)
+struct PlexPerformanceMetricsTests {
+ @MainActor
+ @Test func libraryQueryCacheRemainsBoundedAcrossThousandsOfReturnedItems() async throws {
+ let store = try makeBrowserStore(transientRequestLimit: 8)
+ let library = makeLibrary()
+
+ await store.load(library)
+ for queryIndex in 0..<64 {
+ await store.load(library, searchQuery: "query-\(queryIndex)")
+ }
+
+ let metrics = store.cacheMetrics
+ #expect(metrics.libraryRequestCount == 9)
+ #expect(metrics.transientLibraryRequestCount == 8)
+ #expect(metrics.libraryItemOccurrenceCount == 900)
+ #expect(metrics.uniqueLibraryItemCount == 900)
+ #expect(metrics.transientRequestCountsByLibraryID == [library.id: 8])
+ #expect(metrics.transientRequestLimitPerLibrary == 8)
+ #expect(store.items(in: library).count == 100)
+ #expect(store.items(in: library, searchQuery: "query-0").isEmpty)
+ #expect(store.items(in: library, searchQuery: "query-63").count == 100)
+ }
+
+ @Test func timelineCadenceRetainsConstantStateAcrossTwentyFourHoursOfTicks() {
+ var cadence = PlexTimelineReportCadence()
+ let start = ContinuousClock.now
+ var reportCount = 0
+
+ for second in 0...86_400 {
+ let instant = start.advanced(by: .seconds(second))
+ guard cadence.shouldReport(state: .playing, at: instant) else {
+ continue
+ }
+ cadence.record(state: .playing, at: instant)
+ reportCount += 1
+ }
+
+ #expect(reportCount == 8_641)
+ #expect(cadence.lastReportedState == .playing)
+ #expect(cadence.lastReportInstant == start.advanced(by: .seconds(86_400)))
+ }
+
+ @Test func timelineCadenceReportsStateChangesImmediatelyAndResetsPerMediaItem() {
+ var cadence = PlexTimelineReportCadence()
+ let start = ContinuousClock.now
+ cadence.record(state: .playing, at: start)
+
+ let stateChange = start.advanced(by: .seconds(1))
+ #expect(cadence.shouldReport(state: .paused, at: stateChange))
+ cadence.record(state: .paused, at: stateChange)
+ #expect(!cadence.shouldReport(state: .paused, at: start.advanced(by: .seconds(10))))
+ #expect(cadence.shouldReport(state: .paused, at: start.advanced(by: .seconds(11))))
+
+ cadence.reset()
+ #expect(cadence.shouldReport(state: .playing, at: stateChange))
+ #expect(cadence.lastReportedState == nil)
+ #expect(cadence.lastReportInstant == nil)
+ }
+
+ @MainActor
+ private func makeBrowserStore(transientRequestLimit: Int) throws -> PlexBrowserStore {
+ PerformanceMockURLProtocol.requestHandler = { request in
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: ["X-Plex-Container-Total-Size": "100"]
+ ))
+ switch request.url?.path {
+ case "/media/providers":
+ return (response, Self.mediaProvidersData)
+ case "/library/sections/26/filters":
+ return (response, Self.libraryFiltersData)
+ case "/library/sections/26/sorts":
+ return (response, Self.librarySortsData)
+ default:
+ return (response, Self.libraryPageData(for: request))
+ }
+ }
+ let sessionConfiguration = URLSessionConfiguration.ephemeral
+ sessionConfiguration.protocolClasses = [PerformanceMockURLProtocol.self]
+ let session = URLSession(configuration: sessionConfiguration)
+
+ let suiteName = "PlexBarTests.performanceMetrics.\(UUID().uuidString)"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ let credentials = PlexStoredCredentials(
+ userToken: "user-token",
+ serverToken: "server-token"
+ )
+ let settings = PlexSettingsStore(
+ defaults: defaults,
+ credentialStore: PlexMemoryCredentialStore(credentials: credentials),
+ initialCredentials: credentials
+ )
+ settings.selectedServerIdentifier = "server-id"
+ settings.selectedServerName = "Server"
+ let connectionStore = PlexConnectionStore(settings: settings)
+ connectionStore.activeConnection = PlexResolvedConnection(
+ serverID: "server-id",
+ url: try #require(URL(string: "https://plex.local:32400")),
+ kind: .local,
+ validatedAt: Date()
+ )
+ return PlexBrowserStore(
+ connectionStore: connectionStore,
+ client: PlexAPIClient(session: session),
+ pageSize: 100,
+ transientLibraryRequestLimit: transientRequestLimit
+ )
+ }
+
+ private func makeLibrary() -> PlexLibrary {
+ PlexLibrary(
+ id: "26",
+ title: "Movies",
+ type: .movie,
+ compositePath: nil,
+ artPath: nil,
+ thumbPath: nil,
+ itemCount: 100,
+ secondaryCount: nil,
+ secondaryCountLabel: nil,
+ updatedAt: nil,
+ scannedAt: nil,
+ contentChangedAt: nil,
+ latestAddedAt: nil,
+ latestItemTitle: nil
+ )
+ }
+
+ private static let mediaProvidersData = Data(#"""
+ {
+ "MediaContainer": {
+ "MediaProvider": [{
+ "identifier": "com.plexapp.plugins.library",
+ "Feature": [{
+ "type": "content",
+ "Directory": [{
+ "id": "26",
+ "key": "/library/sections/26",
+ "Pivot": [{
+ "id": "Library",
+ "key": "/library/sections/26/all?type=1",
+ "type": "list"
+ }]
+ }]
+ }]
+ }]
+ }
+ }
+ """#.utf8)
+
+ private static let libraryFiltersData = Data(#"{"MediaContainer":{"Directory":[]}}"#.utf8)
+ private static let librarySortsData = Data(#"{"MediaContainer":{"Directory":[]}}"#.utf8)
+
+ private static func libraryPageData(for request: URLRequest) -> Data {
+ let query = request.url.flatMap {
+ URLComponents(url: $0, resolvingAgainstBaseURL: false)?.queryItems?
+ .first(where: { $0.name == "title" })?.value
+ } ?? "default"
+ let metadata = (0..<100).map { itemIndex in
+ let identifier = "\(query)-\(itemIndex)"
+ return #"{"ratingKey":"\#(identifier)","type":"movie","title":"Item \#(identifier)"}"#
+ }.joined(separator: ",")
+ return Data(#"{"MediaContainer":{"Metadata":[\#(metadata)]}}"#.utf8)
+ }
+}
+
+private final class PerformanceMockURLProtocol: URLProtocol, @unchecked Sendable {
+ nonisolated(unsafe) static var requestHandler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))?
+
+ override class func canInit(with request: URLRequest) -> Bool {
+ true
+ }
+
+ override class func canonicalRequest(for request: URLRequest) -> URLRequest {
+ request
+ }
+
+ override func startLoading() {
+ guard let handler = Self.requestHandler else {
+ client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
+ return
+ }
+ do {
+ let (response, data) = try handler(request)
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: data)
+ client?.urlProtocolDidFinishLoading(self)
+ } catch {
+ client?.urlProtocol(self, didFailWithError: error)
+ }
+ }
+
+ override func stopLoading() {}
+}
diff --git a/PlexBarTests/PlexPersonalRatingTests.swift b/PlexBarTests/PlexPersonalRatingTests.swift
new file mode 100644
index 0000000..23462c4
--- /dev/null
+++ b/PlexBarTests/PlexPersonalRatingTests.swift
@@ -0,0 +1,47 @@
+import Testing
+@testable import PlexBar
+
+struct PlexPersonalRatingTests {
+ @Test func serverValuesMapToFiveStarsWithoutLosingHalfSteps() {
+ #expect(PlexPersonalRating.stars(fromServerValue: nil) == nil)
+ #expect(PlexPersonalRating.stars(fromServerValue: 0) == nil)
+ #expect(PlexPersonalRating.stars(fromServerValue: 1) == 0.5)
+ #expect(PlexPersonalRating.stars(fromServerValue: 2) == 1)
+ #expect(PlexPersonalRating.stars(fromServerValue: 7) == 3.5)
+ #expect(PlexPersonalRating.stars(fromServerValue: 10) == 5)
+ }
+
+ @Test func fiveStarValuesMapBackToThePlexServerScale() {
+ #expect(PlexPersonalRating.serverValue(fromStars: 0.5) == 1)
+ #expect(PlexPersonalRating.serverValue(fromStars: 1) == 2)
+ #expect(PlexPersonalRating.serverValue(fromStars: 3.5) == 7)
+ #expect(PlexPersonalRating.serverValue(fromStars: 5) == 10)
+ #expect(PlexPersonalRating.serverValue(fromStars: 0) == nil)
+ #expect(PlexPersonalRating.serverValue(fromStars: 5.5) == nil)
+ }
+
+ @Test func pointerPositionsSelectHalfStarStepsAcrossFiveVisibleStars() {
+ #expect(PlexPersonalRating.stars(at: 0, controlWidth: 100) == 0.5)
+ #expect(PlexPersonalRating.stars(at: 10, controlWidth: 100) == 0.5)
+ #expect(PlexPersonalRating.stars(at: 10.1, controlWidth: 100) == 1)
+ #expect(PlexPersonalRating.stars(at: 50, controlWidth: 100) == 2.5)
+ #expect(PlexPersonalRating.stars(at: 70, controlWidth: 100) == 3.5)
+ #expect(PlexPersonalRating.stars(at: 100, controlWidth: 100) == 5)
+ }
+
+ @Test func keyboardAndAccessibilityAdjustInHalfStarSteps() {
+ #expect(PlexPersonalRating.adjustedServerValue(from: nil, by: 1) == 1)
+ #expect(PlexPersonalRating.adjustedServerValue(from: 7, by: 1) == 8)
+ #expect(PlexPersonalRating.adjustedServerValue(from: 7, by: -1) == 6)
+ #expect(PlexPersonalRating.adjustedServerValue(from: 10, by: 1) == 10)
+ #expect(PlexPersonalRating.adjustedServerValue(from: 1, by: -1) == nil)
+ }
+
+ @Test func labelsUseTheFiveStarPresentationScale() {
+ #expect(PlexPersonalRating.title(forServerValue: nil) == "Rate")
+ #expect(PlexPersonalRating.title(forServerValue: 2) == "1 Star")
+ #expect(PlexPersonalRating.title(forServerValue: 7) == "3.5 Stars")
+ #expect(PlexPersonalRating.title(forServerValue: 10) == "5 Stars")
+ #expect(PlexPersonalRating.accessibilityValue(forServerValue: 7) == "3.5 stars")
+ }
+}
diff --git a/PlexBarTests/PlexPhotoPresentationTests.swift b/PlexBarTests/PlexPhotoPresentationTests.swift
new file mode 100644
index 0000000..91be3b2
--- /dev/null
+++ b/PlexBarTests/PlexPhotoPresentationTests.swift
@@ -0,0 +1,121 @@
+import PlexModels
+import CoreGraphics
+import Foundation
+import Testing
+@testable import PlexBar
+
+@Suite
+struct PlexPhotoPresentationTests {
+ @Test func usesTheSelectedPhotoPartAndPreservesItsAspectRatio() throws {
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "photo-1",
+ "title": "Vacation",
+ "type": "photo",
+ "thumb": "/library/metadata/photo-1/thumb/12",
+ "Media": [
+ {
+ "width": 1200,
+ "height": 1200,
+ "Part": [{ "key": "/library/parts/first/file.jpeg" }]
+ },
+ {
+ "selected": "1",
+ "width": "6000",
+ "height": "4000",
+ "Part": [
+ { "key": "/library/parts/unselected/file.jpeg" },
+ { "selected": true, "key": "/library/parts/selected/file.jpeg" }
+ ]
+ }
+ ]
+ }
+ """#)
+
+ let presentation = try #require(PlexPhotoPresentation(item: item))
+ #expect(presentation.sourcePath == "/library/parts/selected/file.jpeg")
+ #expect(presentation.fallbackArtworkPath == "/library/metadata/photo-1/thumb/12")
+ #expect(presentation.pixelWidth == 6_000)
+ #expect(presentation.pixelHeight == 4_000)
+ #expect(presentation.dimensionsText == "6,000 × 4,000")
+ #expect(presentation.fittedSize(in: CGSize(width: 900, height: 700)) == CGSize(
+ width: 900,
+ height: 600
+ ))
+ #expect(presentation.requestPixelSize(
+ for: CGSize(width: 900, height: 600),
+ displayScale: 2
+ ) == CGSize(width: 1_800, height: 1_200))
+ }
+
+ @Test func usesServerArtworkWhenTheOriginalPartIsUnavailable() throws {
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "photo-2",
+ "title": "Scanned Photo",
+ "type": "photo",
+ "thumb": "/library/metadata/photo-2/thumb/12"
+ }
+ """#)
+
+ let presentation = try #require(PlexPhotoPresentation(item: item))
+ #expect(presentation.sourcePath == "/library/metadata/photo-2/thumb/12")
+ #expect(presentation.fallbackArtworkPath == nil)
+ #expect(presentation.fittedSize(in: CGSize(width: 900, height: 700)) == CGSize(
+ width: 900,
+ height: 700
+ ))
+ }
+
+ @Test func photosNeverEnterTheAVPlaybackPipeline() throws {
+ let photo = try decodeItem(#"""
+ {
+ "ratingKey": "photo-3",
+ "title": "Photo",
+ "type": "photo",
+ "Media": [{
+ "container": "jpeg",
+ "Part": [{ "key": "/library/parts/700/file.jpeg" }]
+ }]
+ }
+ """#)
+ let movie = try decodeItem(#"""
+ {
+ "ratingKey": "movie-1",
+ "title": "Movie",
+ "type": "movie",
+ "Media": [{
+ "container": "mp4",
+ "videoCodec": "h264",
+ "Part": [{ "key": "/library/parts/701/file.mp4" }]
+ }]
+ }
+ """#)
+
+ #expect(!photo.supportsNativePlayback)
+ #expect(!photo.isPlayable)
+ #expect(photo.defaultPlaybackSource == nil)
+ #expect(photo.playbackSource(mediaIndex: 0) == nil)
+ #expect(movie.supportsNativePlayback)
+ #expect(movie.isPlayable)
+ #expect(movie.defaultPlaybackSource == PlexPlaybackSource(mediaIndex: 0, partIndex: 0))
+ }
+
+ @Test func rejectsNonPhotoMetadataButKeepsAPlaceholderPresentationForAnEmptyPhoto() throws {
+ let movie = try decodeItem(#"""
+ { "ratingKey": "movie-1", "title": "Movie", "type": "movie", "thumb": "/thumb" }
+ """#)
+ let emptyPhoto = try decodeItem(#"""
+ { "ratingKey": "photo-4", "title": "Photo", "type": "photo" }
+ """#)
+
+ #expect(PlexPhotoPresentation(item: movie) == nil)
+ let emptyPresentation = try #require(PlexPhotoPresentation(item: emptyPhoto))
+ #expect(emptyPresentation.sourcePath == nil)
+ #expect(emptyPresentation.fallbackArtworkPath == nil)
+ }
+
+ private func decodeItem(_ json: String) throws -> PlexMediaItem {
+ try JSONDecoder().decode(PlexMediaItem.self, from: Data(json.utf8))
+ }
+}
diff --git a/PlexBarTests/PlexPlayQueueTests.swift b/PlexBarTests/PlexPlayQueueTests.swift
new file mode 100644
index 0000000..2923ae4
--- /dev/null
+++ b/PlexBarTests/PlexPlayQueueTests.swift
@@ -0,0 +1,1641 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+@Suite(.serialized)
+struct PlexPlayQueueTests {
+ @Test func queueMutationRequestsShareValidatedServerPathComponents() throws {
+ #expect(try PlexPlayQueueMutationRequest(
+ queueID: 91,
+ mutation: .shuffled(true)
+ ).endpointPathComponents == ["91", "shuffle"])
+ #expect(try PlexPlayQueueMutationRequest(
+ queueID: 91,
+ mutation: .shuffled(false)
+ ).endpointPathComponents == ["91", "unshuffle"])
+ #expect(try PlexPlayQueueMutationRequest(
+ queueID: 91,
+ mutation: .reset
+ ).endpointPathComponents == ["91", "reset"])
+ #expect(throws: PlexAPIError.self) {
+ _ = try PlexPlayQueueMutationRequest(
+ queueID: 0,
+ mutation: .reset
+ )
+ }
+ }
+
+ @Test func queueItemMutationRequestsShareValidatedServerContracts() throws {
+ let removal = try PlexPlayQueueItemMutationRequest(
+ queueID: 91,
+ mutation: .remove(playQueueItemID: "503")
+ )
+ #expect(removal.endpointPathComponents == ["91", "items", "503"])
+ #expect(removal.method == "DELETE")
+ #expect(removal.queryItems.isEmpty)
+
+ let move = try PlexPlayQueueItemMutationRequest(
+ queueID: 91,
+ mutation: .move(PlexPlayQueueItemMove(
+ playQueueItemID: "504",
+ afterPlayQueueItemID: "502"
+ ))
+ )
+ #expect(move.endpointPathComponents == ["91", "items", "504", "move"])
+ #expect(move.method == "PUT")
+ #expect(move.queryItems == [URLQueryItem(name: "after", value: "502")])
+
+ for invalidIdentifier in ["", " ", "item-503", "50/3"] {
+ #expect(throws: PlexAPIError.self) {
+ _ = try PlexPlayQueueItemMutationRequest(
+ queueID: 91,
+ mutation: .remove(playQueueItemID: invalidIdentifier)
+ )
+ }
+ }
+ #expect(throws: PlexAPIError.self) {
+ _ = try PlexPlayQueueItemMutationRequest(
+ queueID: 91,
+ mutation: .move(PlexPlayQueueItemMove(
+ playQueueItemID: "503",
+ afterPlayQueueItemID: "503"
+ ))
+ )
+ }
+ }
+
+ @Test(arguments: [0, 2, 5])
+ func createsServerAuthoredCinemaQueueWithExactPrefixCount(
+ extrasPrefixCount: Int
+ ) async throws {
+ let capture = RequestCapture()
+ let session = makePlayQueueMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, Self.cinemaQueueData)
+ }
+ let movie = try decodeItem(#"{"ratingKey":"77","key":"/library/metadata/77","title":"Feature","type":"movie","Media":[]}"#)
+
+ var queue = try await PlexAPIClient(session: session).createCinemaPlayQueue(
+ for: movie,
+ extrasPrefixCount: extrasPrefixCount,
+ endpointPath: "/provider/play-queue?source=library",
+ using: try configuration
+ )
+
+ let request = try #require(capture.request)
+ let components = try #require(request.url.flatMap {
+ URLComponents(url: $0, resolvingAgainstBaseURL: false)
+ })
+ #expect(request.httpMethod == "POST")
+ #expect(components.path == "/provider/play-queue")
+ #expect(queryValue("source", in: components) == "library")
+ #expect(queryValue("uri", in: components) == "server://server-id/com.plexapp.plugins.library/library/metadata/77")
+ #expect(queryValue("type", in: components) == "video")
+ #expect(queryValue("key", in: components) == "/library/metadata/77")
+ #expect(queryValue("shuffle", in: components) == "0")
+ #expect(queryValue("repeat", in: components) == "0")
+ #expect(queryValue("continuous", in: components) == "0")
+ #expect(queryValue("extrasPrefixCount", in: components) == String(extrasPrefixCount))
+ #expect(queue.purpose == .cinemaPreplay(primaryRatingKey: "77"))
+ #expect(queue.isCinemaPreplayQueue)
+ #expect(queue.currentItem.ratingKey == "700")
+ #expect(queue.isCurrentCinemaPreplayItem)
+ #expect(!queue.canChangeShuffle)
+ #expect(!queue.canRepeatAll)
+ #expect(!queue.canAdd(movie))
+ #expect(queue.move(.next)?.ratingKey == "77")
+ #expect(queue.isCinemaPreplayQueue)
+ #expect(!queue.isCurrentCinemaPreplayItem)
+ }
+
+ @Test func rejectsCinemaQueueRequestsOutsideTheMovieClientContract() async throws {
+ let movie = try decodeItem(#"{"ratingKey":"77","key":"/library/metadata/77","title":"Feature","type":"movie","Media":[]}"#)
+ let episode = try decodeItem(#"{"ratingKey":"78","key":"/library/metadata/78","title":"Episode","type":"episode","Media":[]}"#)
+
+ for invalidCount in [-1, 6] {
+ await #expect(throws: PlexAPIError.self) {
+ _ = try await PlexAPIClient().createCinemaPlayQueue(
+ for: movie,
+ extrasPrefixCount: invalidCount,
+ endpointPath: "/playQueues",
+ using: try configuration
+ )
+ }
+ }
+ await #expect(throws: PlexAPIError.self) {
+ _ = try await PlexAPIClient().createCinemaPlayQueue(
+ for: episode,
+ extrasPrefixCount: 1,
+ endpointPath: "/playQueues",
+ using: try configuration
+ )
+ }
+ }
+
+ @Test func createsContinuousEpisodeQueueFromDocumentedServerSourceURI() async throws {
+ let capture = RequestCapture()
+ let session = makePlayQueueMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, Self.queueData)
+ }
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "42",
+ "key": "/library/metadata/42",
+ "title": "Current Episode",
+ "type": "episode",
+ "Media": []
+ }
+ """#)
+
+ let queue = try await PlexAPIClient(session: session).createContinuousPlayQueue(
+ for: item,
+ endpointPath: "/playQueues",
+ using: try configuration
+ )
+
+ let request = try #require(capture.request)
+ let components = try #require(request.url.flatMap {
+ URLComponents(url: $0, resolvingAgainstBaseURL: false)
+ })
+ #expect(request.httpMethod == "POST")
+ #expect(components.path == "/playQueues")
+ #expect(queryValue("uri", in: components) == "server://server-id/com.plexapp.plugins.library/library/metadata/42")
+ #expect(queryValue("type", in: components) == "video")
+ #expect(queryValue("key", in: components) == "/library/metadata/42")
+ #expect(queryValue("continuous", in: components) == "1")
+ #expect(queryValue("shuffle", in: components) == "0")
+ #expect(queryValue("repeat", in: components) == "0")
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Pms-Api-Version") == "1.0.0")
+ #expect(queue.id == 91)
+ #expect(queue.currentItem.ratingKey == "42")
+ #expect(queue.canMovePrevious)
+ #expect(queue.canMoveNext)
+ #expect(queue.previousItem?.ratingKey == "41")
+ #expect(queue.nextItem?.ratingKey == "43")
+ }
+
+ @Test func createsContinuousTrackQueueAsDocumentedAudioType() async throws {
+ let capture = RequestCapture()
+ let session = makePlayQueueMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, Self.audioQueueData)
+ }
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "82",
+ "key": "/library/metadata/82",
+ "title": "Chapter 2",
+ "type": "track",
+ "Media": []
+ }
+ """#)
+
+ let queue = try await PlexAPIClient(session: session).createContinuousPlayQueue(
+ for: item,
+ endpointPath: "/provider/play-queue?source=library",
+ using: try configuration
+ )
+
+ let request = try #require(capture.request)
+ let components = try #require(request.url.flatMap {
+ URLComponents(url: $0, resolvingAgainstBaseURL: false)
+ })
+ #expect(request.httpMethod == "POST")
+ #expect(components.path == "/provider/play-queue")
+ #expect(queryValue("source", in: components) == "library")
+ #expect(queryValue("uri", in: components) == "server://server-id/com.plexapp.plugins.library/library/metadata/82")
+ #expect(queryValue("type", in: components) == "audio")
+ #expect(queryValue("key", in: components) == "/library/metadata/82")
+ #expect(queryValue("continuous", in: components) == "1")
+ #expect(queryValue("shuffle", in: components) == "0")
+ #expect(queryValue("repeat", in: components) == "0")
+ #expect(queue.currentItem.ratingKey == "82")
+ #expect(queue.canMovePrevious)
+ #expect(queue.canMoveNext)
+ }
+
+ @Test func createsShowQueueAtTheServerAuthoritativeOnDeckEpisode() async throws {
+ let capture = RequestCapture()
+ let session = makePlayQueueMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, Self.queueData)
+ }
+ let show = try decodeItem(#"{"ratingKey":"7","key":"/library/metadata/7/children","title":"Show","type":"show","Media":[]}"#)
+
+ let queue = try await PlexAPIClient(session: session).createContinuousPlayQueue(
+ for: show,
+ endpointPath: "/provider/play-queue?source=library",
+ using: try configuration
+ )
+
+ let request = try #require(capture.request)
+ let components = try #require(request.url.flatMap {
+ URLComponents(url: $0, resolvingAgainstBaseURL: false)
+ })
+ #expect(request.httpMethod == "POST")
+ #expect(components.path == "/provider/play-queue")
+ #expect(queryValue("source", in: components) == "library")
+ #expect(queryValue("uri", in: components) == "server://server-id/com.plexapp.plugins.library/library/metadata/7/children")
+ #expect(queryValue("type", in: components) == "video")
+ #expect(queryValue("continuous", in: components) == "1")
+ #expect(queryValue("onDeck", in: components) == "1")
+ #expect(queryValue("key", in: components) == nil)
+ #expect(queue.currentItem.playQueueItemID == "502")
+ #expect(queue.currentItem.ratingKey == "42")
+ }
+
+ @Test func onlyShowsAndSeasonsExposeHierarchyOnDeckPlayback() throws {
+ let show = try decodeItem(#"{"ratingKey":"7","key":"/library/metadata/7/children","title":"Show","type":"show"}"#)
+ let season = try decodeItem(#"{"ratingKey":"8","key":"/library/metadata/8/children","title":"Season 1","type":"season"}"#)
+ let episode = try decodeItem(#"{"ratingKey":"9","key":"/library/metadata/9","title":"Episode","type":"episode"}"#)
+ let showWithoutKey = try decodeItem(#"{"ratingKey":"10","title":"Show","type":"show"}"#)
+
+ #expect(show.continuousPlayQueueType == .video)
+ #expect(season.continuousPlayQueueType == .video)
+ #expect(show.continuousPlayQueueUsesOnDeck)
+ #expect(season.continuousPlayQueueUsesOnDeck)
+ #expect(show.supportsHierarchyPlayback)
+ #expect(season.supportsHierarchyPlayback)
+ #expect(!episode.continuousPlayQueueUsesOnDeck)
+ #expect(!episode.supportsHierarchyPlayback)
+ #expect(!showWithoutKey.supportsHierarchyPlayback)
+ }
+
+ @Test func rejectsItemsThatDoNotHaveContinuousQueueSemantics() async throws {
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "7",
+ "key": "/library/metadata/7",
+ "title": "Standalone Movie",
+ "type": "movie",
+ "Media": []
+ }
+ """#)
+
+ await #expect(throws: PlexAPIError.self) {
+ _ = try await PlexAPIClient().createContinuousPlayQueue(
+ for: item,
+ endpointPath: "/playQueues",
+ using: try configuration
+ )
+ }
+ }
+
+ @Test func refreshesAQueueWindowAroundTheExactCurrentQueueItem() async throws {
+ let capture = RequestCapture()
+ let session = makePlayQueueMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, Self.queueData)
+ }
+
+ let page = try await PlexAPIClient(session: session).fetchPlayQueuePage(
+ queueID: 91,
+ endpointPath: "/provider/play-queue?source=library",
+ centeredOn: "502",
+ window: 40,
+ using: try configuration
+ )
+
+ let request = try #require(capture.request)
+ let components = try #require(request.url.flatMap {
+ URLComponents(url: $0, resolvingAgainstBaseURL: false)
+ })
+ #expect(request.httpMethod == "GET")
+ #expect(components.path == "/provider/play-queue/91")
+ #expect(queryValue("source", in: components) == "library")
+ #expect(queryValue("center", in: components) == "502")
+ #expect(queryValue("window", in: components) == "40")
+ #expect(queryValue("includeBefore", in: components) == "1")
+ #expect(queryValue("includeAfter", in: components) == "1")
+ #expect(page.items.map(\.playQueueItemID) == ["501", "502", "503"])
+ }
+
+ @Test func addsItemsThroughTheExactAdvertisedQueueEndpoint() async throws {
+ let capture = RequestCapture()
+ let session = makePlayQueueMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, Self.queueData)
+ }
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "77",
+ "key": "/library/metadata/77",
+ "title": "Queued Movie",
+ "type": "movie",
+ "Media": []
+ }
+ """#)
+ let client = PlexAPIClient(session: session)
+
+ for (insertion, expectedNext) in [
+ (PlexPlayQueueInsertion.next, "1"),
+ (.upNext, "0"),
+ ] {
+ _ = try await client.addToPlayQueue(
+ item,
+ queueID: 91,
+ insertion: insertion,
+ endpointPath: "/provider/play-queue?source=library",
+ using: try configuration
+ )
+
+ let request = try #require(capture.request)
+ let components = try #require(request.url.flatMap {
+ URLComponents(url: $0, resolvingAgainstBaseURL: false)
+ })
+ #expect(request.httpMethod == "PUT")
+ #expect(components.path == "/provider/play-queue/91")
+ #expect(queryValue("source", in: components) == "library")
+ #expect(queryValue("uri", in: components) == "server://server-id/com.plexapp.plugins.library/library/metadata/77")
+ #expect(queryValue("next", in: components) == expectedNext)
+ }
+ }
+
+ @Test func removesAnUpcomingItemThroughTheExactAdvertisedQueueEndpoint() async throws {
+ let capture = RequestCapture()
+ let session = makePlayQueueMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, Self.removedQueueItemData)
+ }
+
+ let page = try await PlexAPIClient(session: session).removePlayQueueItem(
+ queueID: 91,
+ playQueueItemID: "504",
+ endpointPath: "/provider/play-queue?source=library",
+ using: try configuration
+ )
+
+ let request = try #require(capture.request)
+ let components = try #require(request.url.flatMap {
+ URLComponents(url: $0, resolvingAgainstBaseURL: false)
+ })
+ #expect(request.httpMethod == "DELETE")
+ #expect(components.path == "/provider/play-queue/91/items/504")
+ #expect(queryValue("source", in: components) == "library")
+ #expect(page.items.map(\.playQueueItemID) == ["501", "502", "503", "505"])
+ }
+
+ @Test func movesAnUpcomingItemAfterTheExactServerQueueItem() async throws {
+ let capture = RequestCapture()
+ let session = makePlayQueueMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, Self.movedQueueItemData)
+ }
+ let move = PlexPlayQueueItemMove(
+ playQueueItemID: "504",
+ afterPlayQueueItemID: "502"
+ )
+
+ let page = try await PlexAPIClient(session: session).movePlayQueueItem(
+ queueID: 91,
+ move: move,
+ endpointPath: "/provider/play-queue?source=library",
+ using: try configuration
+ )
+
+ let request = try #require(capture.request)
+ let components = try #require(request.url.flatMap {
+ URLComponents(url: $0, resolvingAgainstBaseURL: false)
+ })
+ #expect(request.httpMethod == "PUT")
+ #expect(components.path == "/provider/play-queue/91/items/504/move")
+ #expect(queryValue("source", in: components) == "library")
+ #expect(queryValue("after", in: components) == "502")
+ #expect(page.items.map(\.playQueueItemID) == ["501", "502", "504", "503", "505"])
+ }
+
+ @Test func rejectsNonNumericPlayQueueMutationIdentifiers() async throws {
+ await #expect(throws: PlexAPIError.self) {
+ _ = try await PlexAPIClient().removePlayQueueItem(
+ queueID: 91,
+ playQueueItemID: "not-an-id",
+ endpointPath: "/playQueues",
+ using: try configuration
+ )
+ }
+ await #expect(throws: PlexAPIError.self) {
+ _ = try await PlexAPIClient().movePlayQueueItem(
+ queueID: 91,
+ move: PlexPlayQueueItemMove(
+ playQueueItemID: "504",
+ afterPlayQueueItemID: "other"
+ ),
+ endpointPath: "/playQueues",
+ using: try configuration
+ )
+ }
+ }
+
+ @Test func changesShuffleThroughTheExactAdvertisedQueueEndpoint() async throws {
+ let capture = RequestCapture()
+ let session = makePlayQueueMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ let data = request.url?.path.hasSuffix("/unshuffle") == true
+ ? Self.unshuffledQueueData
+ : Self.shuffledQueueData
+ return (response, data)
+ }
+ let client = PlexAPIClient(session: session)
+
+ let shuffledPage = try await client.setPlayQueueShuffled(
+ true,
+ queueID: 91,
+ endpointPath: "/provider/play-queue?source=library",
+ using: try configuration
+ )
+
+ var request = try #require(capture.request)
+ var components = try #require(request.url.flatMap {
+ URLComponents(url: $0, resolvingAgainstBaseURL: false)
+ })
+ #expect(request.httpMethod == "PUT")
+ #expect(components.path == "/provider/play-queue/91/shuffle")
+ #expect(queryValue("source", in: components) == "library")
+ #expect(shuffledPage.isShuffled == true)
+ #expect(shuffledPage.selectedItemID == "502")
+
+ let unshuffledPage = try await client.setPlayQueueShuffled(
+ false,
+ queueID: 91,
+ endpointPath: "/provider/play-queue?source=library",
+ using: try configuration
+ )
+
+ request = try #require(capture.request)
+ components = try #require(request.url.flatMap {
+ URLComponents(url: $0, resolvingAgainstBaseURL: false)
+ })
+ #expect(request.httpMethod == "PUT")
+ #expect(components.path == "/provider/play-queue/91/unshuffle")
+ #expect(queryValue("source", in: components) == "library")
+ #expect(unshuffledPage.isShuffled == false)
+ #expect(unshuffledPage.selectedItemID == "502")
+ }
+
+ @Test func resetsQueueThroughTheExactAdvertisedQueueEndpoint() async throws {
+ let capture = RequestCapture()
+ let session = makePlayQueueMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, Self.resetQueueData)
+ }
+
+ let page = try await PlexAPIClient(session: session).resetPlayQueue(
+ queueID: 91,
+ endpointPath: "/provider/play-queue?source=library",
+ using: try configuration
+ )
+
+ let request = try #require(capture.request)
+ let components = try #require(request.url.flatMap {
+ URLComponents(url: $0, resolvingAgainstBaseURL: false)
+ })
+ #expect(request.httpMethod == "PUT")
+ #expect(components.path == "/provider/play-queue/91/reset")
+ #expect(queryValue("source", in: components) == "library")
+ #expect(page.selectedItemID == "501")
+ #expect(page.selectedItemOffset == 0)
+ }
+
+ @Test func appliesOnlyAuthoritativeShuffleResponsesThatPreserveTheCurrentItem() throws {
+ let naturalItems = try decodeItems(#"""
+ [
+ {"ratingKey":"41","title":"Previous","playQueueItemID":"501","Media":[]},
+ {"ratingKey":"42","title":"Current","playQueueItemID":"502","Media":[]},
+ {"ratingKey":"43","title":"Next","playQueueItemID":"503","Media":[]}
+ ]
+ """#)
+ var queue = try PlexPlaybackQueue(
+ page: PlexPlayQueuePage(
+ id: 91,
+ version: 1,
+ totalCount: 3,
+ offset: 0,
+ selectedItemID: "502",
+ selectedItemOffset: 1,
+ items: naturalItems,
+ isShuffled: false
+ ),
+ selectedRatingKey: "42"
+ )
+ let shuffledItems = try decodeItems(#"""
+ [
+ {"ratingKey":"43","title":"Next","playQueueItemID":"503","Media":[]},
+ {"ratingKey":"42","title":"Current","playQueueItemID":"502","Media":[]},
+ {"ratingKey":"41","title":"Previous","playQueueItemID":"501","Media":[]}
+ ]
+ """#)
+
+ try queue.applyShuffleMutation(
+ PlexPlayQueuePage(
+ id: 91,
+ version: 2,
+ totalCount: 3,
+ offset: 0,
+ selectedItemID: "502",
+ selectedItemOffset: 1,
+ items: shuffledItems,
+ isShuffled: true
+ ),
+ expectedShuffled: true
+ )
+
+ #expect(queue.isShuffled)
+ #expect(queue.currentItem.playQueueItemID == "502")
+ #expect(queue.presentation.upcomingItems.map(\.playQueueItemID) == ["501"])
+
+ #expect(throws: PlexAPIError.self) {
+ try queue.applyShuffleMutation(
+ PlexPlayQueuePage(
+ id: 91,
+ version: 3,
+ totalCount: 3,
+ offset: 0,
+ selectedItemID: "503",
+ selectedItemOffset: 0,
+ items: shuffledItems,
+ isShuffled: false
+ ),
+ expectedShuffled: false
+ )
+ }
+ #expect(queue.isShuffled)
+ #expect(queue.currentItem.playQueueItemID == "502")
+
+ #expect(throws: PlexAPIError.self) {
+ try queue.applyShuffleMutation(
+ PlexPlayQueuePage(
+ id: 91,
+ version: 3,
+ totalCount: 3,
+ offset: 0,
+ selectedItemID: "502",
+ selectedItemOffset: 1,
+ items: shuffledItems
+ ),
+ expectedShuffled: false
+ )
+ }
+ #expect(queue.isShuffled)
+ #expect(queue.currentItem.playQueueItemID == "502")
+ }
+
+ @Test func derivesOnlyLoadedUpcomingQueueEditRequests() throws {
+ let items = try decodeItems(#"""
+ [
+ {"ratingKey":"41","title":"Previous","playQueueItemID":"501","Media":[]},
+ {"ratingKey":"42","title":"Current","playQueueItemID":"502","Media":[]},
+ {"ratingKey":"43","title":"First Up Next","playQueueItemID":"503","Media":[]},
+ {"ratingKey":"44","title":"Second Up Next","playQueueItemID":"504","Media":[]},
+ {"ratingKey":"45","title":"Last Loaded","playQueueItemID":"505","Media":[]}
+ ]
+ """#)
+ let queue = try PlexPlaybackQueue(
+ page: PlexPlayQueuePage(
+ id: 91,
+ version: 1,
+ totalCount: 8,
+ offset: 0,
+ selectedItemID: "502",
+ selectedItemOffset: 1,
+ items: items
+ ),
+ selectedRatingKey: "42"
+ )
+
+ #expect(!queue.canRemoveUpcomingItem(playQueueItemID: "502"))
+ #expect(queue.canRemoveUpcomingItem(playQueueItemID: "503"))
+ #expect(queue.moveRequest(for: "503", direction: .up) == nil)
+ #expect(queue.moveRequest(for: "503", direction: .down) == PlexPlayQueueItemMove(
+ playQueueItemID: "503",
+ afterPlayQueueItemID: "504"
+ ))
+ #expect(queue.moveRequest(for: "504", direction: .up) == PlexPlayQueueItemMove(
+ playQueueItemID: "504",
+ afterPlayQueueItemID: "502"
+ ))
+ #expect(queue.moveRequest(for: "505", direction: .down) == nil)
+ }
+
+ @Test func derivesAnExactServerMoveFromNativeListOffsets() throws {
+ let queue = try editableQueue()
+
+ #expect(queue.canReorderLoadedUpcomingItems)
+ #expect(queue.moveRequest(
+ fromUpcomingOffsets: IndexSet(integer: 0),
+ toUpcomingOffset: 3
+ ) == PlexPlayQueueItemMove(
+ playQueueItemID: "503",
+ afterPlayQueueItemID: "505"
+ ))
+ #expect(queue.moveRequest(
+ fromUpcomingOffsets: IndexSet(integer: 2),
+ toUpcomingOffset: 0
+ ) == PlexPlayQueueItemMove(
+ playQueueItemID: "505",
+ afterPlayQueueItemID: "502"
+ ))
+ #expect(queue.moveRequest(
+ fromUpcomingOffsets: IndexSet(integer: 1),
+ toUpcomingOffset: 2
+ ) == nil)
+ #expect(queue.moveRequest(
+ fromUpcomingOffsets: IndexSet([0, 1]),
+ toUpcomingOffset: 3
+ ) == nil)
+ #expect(queue.moveRequest(
+ fromUpcomingOffsets: IndexSet(integer: 0),
+ toUpcomingOffset: 4
+ ) == nil)
+ }
+
+ @Test func appliesOnlyServerConfirmedRemovalThatPreservesNowPlaying() throws {
+ var queue = try editableQueue()
+ let removedItems = try decodeItems(#"""
+ [
+ {"ratingKey":"41","title":"Previous","playQueueItemID":"501","Media":[]},
+ {"ratingKey":"42","title":"Current","playQueueItemID":"502","Media":[]},
+ {"ratingKey":"43","title":"First Up Next","playQueueItemID":"503","Media":[]},
+ {"ratingKey":"45","title":"Last Up Next","playQueueItemID":"505","Media":[]}
+ ]
+ """#)
+
+ try queue.applyRemoval(
+ PlexPlayQueuePage(
+ id: 91,
+ version: 2,
+ totalCount: 4,
+ offset: 0,
+ selectedItemID: "502",
+ selectedItemOffset: 1,
+ items: removedItems
+ ),
+ removedPlayQueueItemID: "504"
+ )
+
+ #expect(queue.currentItem.playQueueItemID == "502")
+ #expect(queue.totalCount == 4)
+ #expect(queue.presentation.upcomingItems.map(\.playQueueItemID) == ["503", "505"])
+
+ #expect(throws: PlexAPIError.self) {
+ try queue.applyRemoval(
+ PlexPlayQueuePage(
+ id: 91,
+ version: 3,
+ totalCount: 3,
+ offset: 0,
+ selectedItemID: "503",
+ selectedItemOffset: 1,
+ items: removedItems
+ ),
+ removedPlayQueueItemID: "505"
+ )
+ }
+ #expect(queue.currentItem.playQueueItemID == "502")
+ }
+
+ @Test func appliesOnlyServerConfirmedMoveThatPreservesNowPlaying() throws {
+ var queue = try editableQueue()
+ let move = try #require(queue.moveRequest(for: "504", direction: .up))
+ let movedItems = try decodeItems(#"""
+ [
+ {"ratingKey":"41","title":"Previous","playQueueItemID":"501","Media":[]},
+ {"ratingKey":"42","title":"Current","playQueueItemID":"502","Media":[]},
+ {"ratingKey":"44","title":"Second Up Next","playQueueItemID":"504","Media":[]},
+ {"ratingKey":"43","title":"First Up Next","playQueueItemID":"503","Media":[]},
+ {"ratingKey":"45","title":"Last Up Next","playQueueItemID":"505","Media":[]}
+ ]
+ """#)
+
+ try queue.applyMove(
+ PlexPlayQueuePage(
+ id: 91,
+ version: 2,
+ totalCount: 5,
+ offset: 0,
+ selectedItemID: "502",
+ selectedItemOffset: 1,
+ items: movedItems
+ ),
+ request: move
+ )
+
+ #expect(queue.currentItem.playQueueItemID == "502")
+ #expect(queue.presentation.upcomingItems.map(\.playQueueItemID) == ["504", "503", "505"])
+
+ #expect(throws: PlexAPIError.self) {
+ try queue.applyMove(
+ PlexPlayQueuePage(
+ id: 91,
+ version: 3,
+ totalCount: 5,
+ offset: 0,
+ selectedItemID: "502",
+ selectedItemOffset: 1,
+ items: movedItems
+ ),
+ request: PlexPlayQueueItemMove(
+ playQueueItemID: "503",
+ afterPlayQueueItemID: "505"
+ )
+ )
+ }
+ #expect(queue.presentation.upcomingItems.map(\.playQueueItemID) == ["504", "503", "505"])
+ }
+
+ @Test func appliesAConfirmedArbitraryLoadedMove() throws {
+ var queue = try editableQueue()
+ let move = try #require(queue.moveRequest(
+ fromUpcomingOffsets: IndexSet(integer: 0),
+ toUpcomingOffset: 3
+ ))
+ let movedItems = try decodeItems(#"""
+ [
+ {"ratingKey":"41","title":"Previous","playQueueItemID":"501","Media":[]},
+ {"ratingKey":"42","title":"Current","playQueueItemID":"502","Media":[]},
+ {"ratingKey":"44","title":"Second Up Next","playQueueItemID":"504","Media":[]},
+ {"ratingKey":"45","title":"Last Up Next","playQueueItemID":"505","Media":[]},
+ {"ratingKey":"43","title":"First Up Next","playQueueItemID":"503","Media":[]}
+ ]
+ """#)
+
+ try queue.applyMove(
+ PlexPlayQueuePage(
+ id: 91,
+ version: 2,
+ totalCount: 5,
+ offset: 0,
+ selectedItemID: "502",
+ selectedItemOffset: 1,
+ items: movedItems
+ ),
+ request: move
+ )
+
+ #expect(queue.currentItem.playQueueItemID == "502")
+ #expect(queue.presentation.upcomingItems.map(\.playQueueItemID) == ["504", "505", "503"])
+ }
+
+ @Test func cinemaPreplayQueuesCannotBeEdited() throws {
+ let items = try decodeItems(#"""
+ [
+ {"ratingKey":"700","title":"Trailer","playQueueItemID":"701","Media":[]},
+ {"ratingKey":"77","title":"Feature","playQueueItemID":"702","Media":[]}
+ ]
+ """#)
+ let queue = try PlexPlaybackQueue(
+ page: PlexPlayQueuePage(
+ id: 93,
+ version: 1,
+ totalCount: 2,
+ offset: 0,
+ selectedItemID: "701",
+ selectedItemOffset: 0,
+ items: items
+ ),
+ selectedRatingKey: "700",
+ purpose: .cinemaPreplay(primaryRatingKey: "77")
+ )
+
+ #expect(!queue.canRemoveUpcomingItem(playQueueItemID: "702"))
+ #expect(!queue.canReorderLoadedUpcomingItems)
+ #expect(queue.moveRequest(for: "702", direction: .up) == nil)
+ #expect(queue.moveRequest(for: "702", direction: .down) == nil)
+ }
+
+ @Test func disablesShuffleWhenPlexReturnsAnUpNextRegion() throws {
+ let items = try decodeItems(#"""
+ [
+ {"ratingKey":"42","title":"Current","playQueueItemID":"502","Media":[]},
+ {"ratingKey":"43","title":"Queued Next","playQueueItemID":"503","Media":[]}
+ ]
+ """#)
+ let queue = try PlexPlaybackQueue(
+ page: PlexPlayQueuePage(
+ id: 91,
+ version: 2,
+ totalCount: 2,
+ offset: 0,
+ selectedItemID: "502",
+ selectedItemOffset: 0,
+ items: items,
+ isShuffled: false,
+ lastAddedItemID: "503"
+ ),
+ selectedRatingKey: "42"
+ )
+
+ #expect(queue.hasUpNextRegion)
+ #expect(!queue.canChangeShuffle)
+ #expect(!queue.presentation.isShuffled)
+ }
+
+ @Test func queueInsertionRequiresCompatibleMediaAndPreservesTheCurrentItem() throws {
+ let initialItems = try decodeItems(#"""
+ [
+ {"ratingKey":"42","key":"/library/metadata/42","title":"Current","type":"episode","playQueueItemID":"502","Media":[]},
+ {"ratingKey":"43","key":"/library/metadata/43","title":"Original Next","type":"episode","playQueueItemID":"503","Media":[]}
+ ]
+ """#)
+ var queue = try PlexPlaybackQueue(
+ page: PlexPlayQueuePage(
+ id: 91,
+ version: 1,
+ totalCount: 2,
+ offset: 0,
+ selectedItemID: "502",
+ selectedItemOffset: 0,
+ items: initialItems
+ ),
+ selectedRatingKey: "42"
+ )
+ let movie = try decodeItem(#"{"ratingKey":"77","key":"/library/metadata/77","title":"Movie","type":"movie","Media":[]}"#)
+ let track = try decodeItem(#"{"ratingKey":"88","key":"/library/metadata/88","title":"Track","type":"track","Media":[]}"#)
+ let missingKey = try decodeItem(#"{"ratingKey":"78","title":"Missing Key","type":"movie","Media":[]}"#)
+
+ #expect(queue.canAdd(movie))
+ #expect(!queue.canAdd(track))
+ #expect(!queue.canAdd(missingKey))
+
+ let addedItems = try decodeItems(#"""
+ [
+ {"ratingKey":"42","key":"/library/metadata/42","title":"Current","type":"episode","playQueueItemID":"502","Media":[]},
+ {"ratingKey":"77","key":"/library/metadata/77","title":"Queued Movie","type":"movie","playQueueItemID":"504","Media":[]},
+ {"ratingKey":"43","key":"/library/metadata/43","title":"Original Next","type":"episode","playQueueItemID":"503","Media":[]}
+ ]
+ """#)
+ try queue.applyAddition(PlexPlayQueuePage(
+ id: 91,
+ version: 2,
+ totalCount: 3,
+ offset: 0,
+ selectedItemID: "502",
+ selectedItemOffset: 0,
+ items: addedItems,
+ isShuffled: false,
+ lastAddedItemID: "504"
+ ))
+
+ #expect(queue.currentItem.playQueueItemID == "502")
+ #expect(queue.presentation.upcomingItems.map(\.playQueueItemID) == ["504", "503"])
+ #expect(queue.hasUpNextRegion)
+ #expect(!queue.canChangeShuffle)
+
+ #expect(throws: PlexAPIError.self) {
+ try queue.applyAddition(PlexPlayQueuePage(
+ id: 91,
+ version: 3,
+ totalCount: 3,
+ offset: 0,
+ selectedItemID: "504",
+ selectedItemOffset: 1,
+ items: addedItems,
+ lastAddedItemID: "504"
+ ))
+ }
+ #expect(queue.currentItem.playQueueItemID == "502")
+ }
+
+ @Test @MainActor func activeQueueAcceptsItemsOnlyFromItsOriginalServer() throws {
+ let defaultsName = "PlexPlayQueueTests.\(UUID().uuidString)"
+ let defaults = try #require(UserDefaults(suiteName: defaultsName))
+ defer { defaults.removePersistentDomain(forName: defaultsName) }
+ let credentials = PlexStoredCredentials(userToken: "", serverToken: "server-token")
+ let settings = PlexSettingsStore(
+ defaults: defaults,
+ credentialStore: PlexMemoryCredentialStore(credentials: credentials),
+ initialCredentials: credentials
+ )
+ settings.selectedServerIdentifier = "server-id"
+ let browserStore = PlexBrowserStore(
+ connectionStore: PlexConnectionStore(settings: settings),
+ playbackCapabilities: PlexPlaybackCapabilities(
+ directPlayContainers: [],
+ directPlayVideoCodecs: [],
+ directPlayAudioCodecs: []
+ )
+ )
+ let currentItems = try decodeItems(#"""
+ [
+ {"ratingKey":"42","key":"/library/metadata/42","title":"Current","type":"episode","playQueueItemID":"502","Media":[]}
+ ]
+ """#)
+ let queue = try PlexPlaybackQueue(
+ page: PlexPlayQueuePage(
+ id: 91,
+ version: 1,
+ totalCount: 1,
+ offset: 0,
+ selectedItemID: "502",
+ selectedItemOffset: 0,
+ items: currentItems
+ ),
+ selectedRatingKey: "42"
+ )
+ let plan = PlexPlaybackPlan(
+ url: try #require(URL(string: "https://plex.local/video.mp4")),
+ method: .directPlay,
+ mediaKind: .video,
+ sessionIdentifier: "queue-server-scope",
+ ratingKey: "42",
+ duration: 120,
+ startTime: 0,
+ source: PlexPlaybackSource(mediaIndex: 0, partIndex: 0),
+ usesServerMediaSelection: false
+ )
+ let presentation = PlexPlaybackPresentation(
+ item: currentItems[0],
+ plan: plan,
+ queue: queue,
+ videoQuality: .original,
+ serverIdentifier: "server-id"
+ )
+ let coordinator = PlexPlayerCoordinator()
+ coordinator.present(presentation)
+ let session = coordinator.session(for: presentation, browserStore: browserStore)
+ defer { coordinator.close(session) }
+ let candidate = try decodeItem(#"{"ratingKey":"77","key":"/library/metadata/77","title":"Movie","type":"movie","Media":[]}"#)
+
+ #expect(session.canAddToQueue(candidate))
+
+ settings.selectedServerIdentifier = "another-server"
+
+ #expect(!session.canAddToQueue(candidate))
+ }
+
+ @Test func appliesOnlyAuthoritativeQueueResetsToTheFirstStableItem() throws {
+ let items = try decodeItems(#"""
+ [
+ {"ratingKey":"41","title":"First","playQueueItemID":"501","Media":[]},
+ {"ratingKey":"42","title":"Current","playQueueItemID":"502","Media":[]},
+ {"ratingKey":"43","title":"Last","playQueueItemID":"503","Media":[]}
+ ]
+ """#)
+ var queue = try PlexPlaybackQueue(
+ page: PlexPlayQueuePage(
+ id: 91,
+ version: 1,
+ totalCount: 3,
+ offset: 0,
+ selectedItemID: "503",
+ selectedItemOffset: 2,
+ items: items
+ ),
+ selectedRatingKey: "43"
+ )
+
+ try queue.applyReset(PlexPlayQueuePage(
+ id: 91,
+ version: 2,
+ totalCount: 3,
+ offset: 0,
+ selectedItemID: "501",
+ selectedItemOffset: 0,
+ items: items
+ ))
+ #expect(queue.currentAbsoluteIndex == 0)
+ #expect(queue.currentItem.playQueueItemID == "501")
+ #expect(queue.canRepeatAll)
+
+ #expect(throws: PlexAPIError.self) {
+ try queue.applyReset(PlexPlayQueuePage(
+ id: 91,
+ version: 3,
+ totalCount: 3,
+ offset: 0,
+ selectedItemID: "502",
+ selectedItemOffset: 1,
+ items: items
+ ))
+ }
+ #expect(queue.currentItem.playQueueItemID == "501")
+ }
+
+ @Test func queueNavigationPreservesAbsolutePositionAcrossServerWindows() throws {
+ let firstItems = try decodeItems(#"""
+ [
+ {"ratingKey":"42","title":"Current","playQueueItemID":"502","Media":[]},
+ {"ratingKey":"43","title":"Next","playQueueItemID":"503","Media":[]}
+ ]
+ """#)
+ var queue = try PlexPlaybackQueue(
+ page: PlexPlayQueuePage(
+ id: 91,
+ version: 1,
+ totalCount: 100,
+ offset: 50,
+ selectedItemID: "502",
+ selectedItemOffset: 50,
+ items: firstItems
+ ),
+ selectedRatingKey: "42"
+ )
+
+ #expect(queue.currentAbsoluteIndex == 50)
+ #expect(queue.needsWindowRefresh(for: .previous))
+ #expect(!queue.needsWindowRefresh(for: .next))
+ #expect(queue.move(.next)?.playQueueItemID == "503")
+ #expect(queue.currentAbsoluteIndex == 51)
+
+ let refreshedItems = try decodeItems(#"""
+ [
+ {"ratingKey":"42","title":"Current","playQueueItemID":"502","Media":[]},
+ {"ratingKey":"43","title":"Next","playQueueItemID":"503","Media":[]},
+ {"ratingKey":"44","title":"After Next","playQueueItemID":"504","Media":[]}
+ ]
+ """#)
+ try queue.replaceWindow(
+ with: PlexPlayQueuePage(
+ id: 91,
+ version: 2,
+ totalCount: 100,
+ offset: nil,
+ selectedItemID: nil,
+ selectedItemOffset: nil,
+ items: refreshedItems
+ ),
+ centeredOn: "503"
+ )
+
+ #expect(queue.currentAbsoluteIndex == 51)
+ #expect(queue.move(.next)?.playQueueItemID == "504")
+ #expect(queue.currentAbsoluteIndex == 52)
+ }
+
+ @Test func queuePresentationSeparatesLoadedItemsFromItemsRemainingOnServer() throws {
+ let items = try decodeItems(#"""
+ [
+ {"ratingKey":"42","title":"Current","playQueueItemID":"502","Media":[]},
+ {"ratingKey":"43","title":"Next","playQueueItemID":"503","Media":[]},
+ {"ratingKey":"44","title":"Later","playQueueItemID":"504","Media":[]}
+ ]
+ """#)
+ let queue = try PlexPlaybackQueue(
+ page: PlexPlayQueuePage(
+ id: 91,
+ version: 2,
+ totalCount: 100,
+ offset: 50,
+ selectedItemID: "502",
+ selectedItemOffset: 50,
+ items: items
+ ),
+ selectedRatingKey: "42"
+ )
+
+ let presentation = queue.presentation
+ #expect(presentation.currentItem.playQueueItemID == "502")
+ #expect(presentation.upcomingItems.map(\.playQueueItemID) == ["503", "504"])
+ #expect(presentation.currentPosition == 51)
+ #expect(presentation.totalCount == 100)
+ #expect(presentation.remainingCount == 49)
+ #expect(presentation.unloadedRemainingCount == 47)
+ #expect(presentation.canRemoveUpcomingItems)
+ #expect(presentation.canReorderUpcomingItems)
+ #expect(presentation.canMoveUpcomingItem(
+ playQueueItemID: "503",
+ direction: .up
+ ) == false)
+ #expect(presentation.canMoveUpcomingItem(
+ playQueueItemID: "503",
+ direction: .down
+ ))
+ #expect(presentation.canMoveUpcomingItem(
+ playQueueItemID: "504",
+ direction: .up
+ ))
+ #expect(presentation.canMoveUpcomingItem(
+ playQueueItemID: "504",
+ direction: .down
+ ) == false)
+ }
+
+ @Test func movesDirectlyToAnAuthoritativeLoadedQueueItem() throws {
+ let items = try decodeItems(#"""
+ [
+ {"ratingKey":"42","title":"Current","playQueueItemID":"502","Media":[]},
+ {"ratingKey":"43","title":"Next","playQueueItemID":"503","Media":[]},
+ {"ratingKey":"44","title":"Later","playQueueItemID":"504","Media":[]}
+ ]
+ """#)
+ var queue = try PlexPlaybackQueue(
+ page: PlexPlayQueuePage(
+ id: 91,
+ version: 2,
+ totalCount: 3,
+ offset: 0,
+ selectedItemID: "502",
+ selectedItemOffset: 0,
+ items: items
+ ),
+ selectedRatingKey: "42"
+ )
+
+ #expect(queue.move(toPlayQueueItemID: "504")?.ratingKey == "44")
+ #expect(queue.currentAbsoluteIndex == 2)
+ #expect(queue.presentation.currentPosition == 3)
+ #expect(queue.presentation.upcomingItems.isEmpty)
+ #expect(queue.move(toPlayQueueItemID: "missing") == nil)
+ }
+
+ @Test func rejectsQueueWindowsWithoutStableQueueItemIdentifiers() throws {
+ let invalidItems = try decodeItems(#"""
+ [
+ {"ratingKey":"42","title":"Current","Media":[]},
+ {"ratingKey":"43","title":"Next","playQueueItemID":"503","Media":[]}
+ ]
+ """#)
+
+ #expect(throws: PlexAPIError.self) {
+ _ = try PlexPlaybackQueue(
+ page: PlexPlayQueuePage(
+ id: 91,
+ version: 1,
+ totalCount: 2,
+ offset: 0,
+ selectedItemID: nil,
+ selectedItemOffset: 0,
+ items: invalidItems
+ ),
+ selectedRatingKey: "42"
+ )
+ }
+
+ let duplicateItems = try decodeItems(#"""
+ [
+ {"ratingKey":"42","title":"Current","playQueueItemID":"502","Media":[]},
+ {"ratingKey":"43","title":"Next","playQueueItemID":"502","Media":[]}
+ ]
+ """#)
+ #expect(throws: PlexAPIError.self) {
+ _ = try PlexPlaybackQueue(
+ page: PlexPlayQueuePage(
+ id: 91,
+ version: 1,
+ totalCount: 2,
+ offset: 0,
+ selectedItemID: "502",
+ selectedItemOffset: 0,
+ items: duplicateItems
+ ),
+ selectedRatingKey: "42"
+ )
+ }
+ }
+
+ @Test func rejectsReplacementWindowsWithoutStableQueueItemIdentifiers() throws {
+ let validItems = try decodeItems(#"""
+ [
+ {"ratingKey":"42","title":"Current","playQueueItemID":"502","Media":[]}
+ ]
+ """#)
+ var queue = try PlexPlaybackQueue(
+ page: PlexPlayQueuePage(
+ id: 91,
+ version: 1,
+ totalCount: 2,
+ offset: 0,
+ selectedItemID: "502",
+ selectedItemOffset: 0,
+ items: validItems
+ ),
+ selectedRatingKey: "42"
+ )
+ let invalidItems = try decodeItems(#"""
+ [
+ {"ratingKey":"42","title":"Current","playQueueItemID":"502","Media":[]},
+ {"ratingKey":"43","title":"Next","Media":[]}
+ ]
+ """#)
+
+ #expect(throws: PlexAPIError.self) {
+ try queue.replaceWindow(
+ with: PlexPlayQueuePage(
+ id: 91,
+ version: 2,
+ totalCount: 2,
+ offset: 0,
+ selectedItemID: "502",
+ selectedItemOffset: 0,
+ items: invalidItems
+ ),
+ centeredOn: "502"
+ )
+ }
+
+
+ let duplicateItems = try decodeItems(#"""
+ [
+ {"ratingKey":"42","title":"Current","playQueueItemID":"502","Media":[]},
+ {"ratingKey":"43","title":"Next","playQueueItemID":"502","Media":[]}
+ ]
+ """#)
+ #expect(throws: PlexAPIError.self) {
+ try queue.replaceWindow(
+ with: PlexPlayQueuePage(
+ id: 91,
+ version: 2,
+ totalCount: 2,
+ offset: 0,
+ selectedItemID: "502",
+ selectedItemOffset: 0,
+ items: duplicateItems
+ ),
+ centeredOn: "502"
+ )
+ }
+ }
+
+ @Test func marksCompletedMetadataPlayedWithTheDiscoveredPutContract() async throws {
+ let capture = RequestCapture()
+ let session = makePlayQueueMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, Data())
+ }
+
+ try await PlexAPIClient(session: session).setWatched(
+ true,
+ ratingKey: "42",
+ endpoints: timelineEndpoints,
+ using: try configuration
+ )
+
+ let request = try #require(capture.request)
+ let components = try #require(request.url.flatMap {
+ URLComponents(url: $0, resolvingAgainstBaseURL: false)
+ })
+ #expect(request.httpMethod == "PUT")
+ #expect(components.path == "/provider/played")
+ #expect(queryValue("identifier", in: components) == "custom.library.provider")
+ #expect(queryValue("key", in: components) == "42")
+ }
+
+ @Test func marksMetadataUnplayedWithTheDiscoveredPutContract() async throws {
+ let capture = RequestCapture()
+ let session = makePlayQueueMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, Data())
+ }
+
+ try await PlexAPIClient(session: session).setWatched(
+ false,
+ ratingKey: "42",
+ endpoints: timelineEndpoints,
+ using: try configuration
+ )
+
+ let request = try #require(capture.request)
+ let components = try #require(request.url.flatMap {
+ URLComponents(url: $0, resolvingAgainstBaseURL: false)
+ })
+ #expect(request.httpMethod == "PUT")
+ #expect(components.path == "/provider/unplayed")
+ #expect(queryValue("identifier", in: components) == "custom.library.provider")
+ #expect(queryValue("key", in: components) == "42")
+ }
+
+ @Test func rejectsWatchedMutationWhenTheProviderDoesNotAdvertiseIt() async throws {
+ let endpoints = PlexLibraryProviderEndpoints(
+ providerIdentifier: "custom.library.provider",
+ timelinePath: nil,
+ scrobblePath: nil,
+ unscrobblePath: nil,
+ playQueuePath: "/provider/play-queue",
+ ratePath: nil,
+ metadataPath: nil,
+ removeFromContinueWatchingPath: nil,
+ canManage: false
+ )
+
+ await #expect(throws: PlexAPIError.self) {
+ try await PlexAPIClient().setWatched(
+ true,
+ ratingKey: "42",
+ endpoints: endpoints,
+ using: try configuration
+ )
+ }
+ }
+
+ private var timelineEndpoints: PlexLibraryProviderEndpoints {
+ PlexLibraryProviderEndpoints(
+ providerIdentifier: "custom.library.provider",
+ timelinePath: "/provider/timeline",
+ scrobblePath: "/provider/played",
+ unscrobblePath: "/provider/unplayed",
+ playQueuePath: "/provider/play-queue",
+ ratePath: nil,
+ metadataPath: nil,
+ removeFromContinueWatchingPath: nil,
+ canManage: false
+ )
+ }
+
+ private var configuration: PlexConnectionConfiguration {
+ get throws {
+ PlexConnectionConfiguration(
+ serverURL: try #require(URL(string: "https://plex.local:32400")),
+ token: "server-token",
+ clientContext: PlexClientContext(clientIdentifier: "client-123"),
+ serverIdentifier: "server-id"
+ )
+ }
+ }
+
+ private func queryValue(_ name: String, in components: URLComponents) -> String? {
+ components.queryItems?.first { $0.name == name }?.value
+ }
+
+ private func decodeItem(_ json: String) throws -> PlexMediaItem {
+ try JSONDecoder().decode(PlexMediaItem.self, from: Data(json.utf8))
+ }
+
+ private func decodeItems(_ json: String) throws -> [PlexMediaItem] {
+ try JSONDecoder().decode([PlexMediaItem].self, from: Data(json.utf8))
+ }
+
+ private func editableQueue() throws -> PlexPlaybackQueue {
+ let items = try decodeItems(#"""
+ [
+ {"ratingKey":"41","title":"Previous","playQueueItemID":"501","Media":[]},
+ {"ratingKey":"42","title":"Current","playQueueItemID":"502","Media":[]},
+ {"ratingKey":"43","title":"First Up Next","playQueueItemID":"503","Media":[]},
+ {"ratingKey":"44","title":"Second Up Next","playQueueItemID":"504","Media":[]},
+ {"ratingKey":"45","title":"Last Up Next","playQueueItemID":"505","Media":[]}
+ ]
+ """#)
+ return try PlexPlaybackQueue(
+ page: PlexPlayQueuePage(
+ id: 91,
+ version: 1,
+ totalCount: 5,
+ offset: 0,
+ selectedItemID: "502",
+ selectedItemOffset: 1,
+ items: items
+ ),
+ selectedRatingKey: "42"
+ )
+ }
+
+ private static let queueData = Data(#"""
+ {
+ "MediaContainer": {
+ "playQueueID": "91",
+ "playQueueVersion": "3",
+ "playQueueTotalCount": "3",
+ "playQueueSelectedItemID": "502",
+ "playQueueSelectedItemOffset": "1",
+ "offset": "0",
+ "Metadata": [
+ {"ratingKey":"41","key":"/library/metadata/41","title":"Previous","type":"episode","playQueueItemID":"501","Media":[]},
+ {"ratingKey":"42","key":"/library/metadata/42","title":"Current","type":"episode","playQueueItemID":"502","Media":[]},
+ {"ratingKey":"43","key":"/library/metadata/43","title":"Next","type":"episode","playQueueItemID":"503","Media":[]}
+ ]
+ }
+ }
+ """#.utf8)
+
+ private static let cinemaQueueData = Data(#"""
+ {
+ "MediaContainer": {
+ "playQueueID": "93",
+ "playQueueVersion": "1",
+ "playQueueTotalCount": "2",
+ "playQueueSelectedItemID": "701",
+ "playQueueSelectedItemOffset": "0",
+ "offset": "0",
+ "Metadata": [
+ {"ratingKey":"700","key":"/library/metadata/700","title":"Trailer","type":"clip","subtype":"trailer","playQueueItemID":"701","Media":[]},
+ {"ratingKey":"77","key":"/library/metadata/77","title":"Feature","type":"movie","playQueueItemID":"702","Media":[]}
+ ]
+ }
+ }
+ """#.utf8)
+
+ private static let audioQueueData = Data(#"""
+ {
+ "MediaContainer": {
+ "playQueueID": "92",
+ "playQueueVersion": "1",
+ "playQueueTotalCount": "3",
+ "playQueueSelectedItemID": "602",
+ "playQueueSelectedItemOffset": "1",
+ "offset": "0",
+ "Metadata": [
+ {"ratingKey":"81","key":"/library/metadata/81","title":"Chapter 1","type":"track","playQueueItemID":"601","Media":[]},
+ {"ratingKey":"82","key":"/library/metadata/82","title":"Chapter 2","type":"track","playQueueItemID":"602","Media":[]},
+ {"ratingKey":"83","key":"/library/metadata/83","title":"Chapter 3","type":"track","playQueueItemID":"603","Media":[]}
+ ]
+ }
+ }
+ """#.utf8)
+
+ private static let shuffledQueueData = Data(#"""
+ {
+ "MediaContainer": {
+ "playQueueID": "91",
+ "playQueueVersion": "4",
+ "playQueueTotalCount": "3",
+ "playQueueSelectedItemID": "502",
+ "playQueueSelectedItemOffset": "1",
+ "playQueueShuffled": "1",
+ "offset": "0",
+ "Metadata": [
+ {"ratingKey":"43","key":"/library/metadata/43","title":"Next","type":"episode","playQueueItemID":"503","Media":[]},
+ {"ratingKey":"42","key":"/library/metadata/42","title":"Current","type":"episode","playQueueItemID":"502","Media":[]},
+ {"ratingKey":"41","key":"/library/metadata/41","title":"Previous","type":"episode","playQueueItemID":"501","Media":[]}
+ ]
+ }
+ }
+ """#.utf8)
+
+ private static let unshuffledQueueData = Data(#"""
+ {
+ "MediaContainer": {
+ "playQueueID": "91",
+ "playQueueVersion": "5",
+ "playQueueTotalCount": "3",
+ "playQueueSelectedItemID": "502",
+ "playQueueSelectedItemOffset": "1",
+ "playQueueShuffled": false,
+ "offset": "0",
+ "Metadata": [
+ {"ratingKey":"41","key":"/library/metadata/41","title":"Previous","type":"episode","playQueueItemID":"501","Media":[]},
+ {"ratingKey":"42","key":"/library/metadata/42","title":"Current","type":"episode","playQueueItemID":"502","Media":[]},
+ {"ratingKey":"43","key":"/library/metadata/43","title":"Next","type":"episode","playQueueItemID":"503","Media":[]}
+ ]
+ }
+ }
+ """#.utf8)
+
+ private static let resetQueueData = Data(#"""
+ {
+ "MediaContainer": {
+ "playQueueID": "91",
+ "playQueueVersion": "6",
+ "playQueueTotalCount": "3",
+ "playQueueSelectedItemID": "501",
+ "playQueueSelectedItemOffset": "0",
+ "playQueueShuffled": false,
+ "offset": "0",
+ "Metadata": [
+ {"ratingKey":"41","key":"/library/metadata/41","title":"First","type":"episode","playQueueItemID":"501","Media":[]},
+ {"ratingKey":"42","key":"/library/metadata/42","title":"Middle","type":"episode","playQueueItemID":"502","Media":[]},
+ {"ratingKey":"43","key":"/library/metadata/43","title":"Last","type":"episode","playQueueItemID":"503","Media":[]}
+ ]
+ }
+ }
+ """#.utf8)
+
+ private static let removedQueueItemData = Data(#"""
+ {
+ "MediaContainer": {
+ "playQueueID": "91",
+ "playQueueVersion": "2",
+ "playQueueTotalCount": "4",
+ "playQueueSelectedItemID": "502",
+ "playQueueSelectedItemOffset": "1",
+ "offset": "0",
+ "Metadata": [
+ {"ratingKey":"41","key":"/library/metadata/41","title":"Previous","type":"episode","playQueueItemID":"501","Media":[]},
+ {"ratingKey":"42","key":"/library/metadata/42","title":"Current","type":"episode","playQueueItemID":"502","Media":[]},
+ {"ratingKey":"43","key":"/library/metadata/43","title":"First Up Next","type":"episode","playQueueItemID":"503","Media":[]},
+ {"ratingKey":"45","key":"/library/metadata/45","title":"Last Up Next","type":"episode","playQueueItemID":"505","Media":[]}
+ ]
+ }
+ }
+ """#.utf8)
+
+ private static let movedQueueItemData = Data(#"""
+ {
+ "MediaContainer": {
+ "playQueueID": "91",
+ "playQueueVersion": "2",
+ "playQueueTotalCount": "5",
+ "playQueueSelectedItemID": "502",
+ "playQueueSelectedItemOffset": "1",
+ "offset": "0",
+ "Metadata": [
+ {"ratingKey":"41","key":"/library/metadata/41","title":"Previous","type":"episode","playQueueItemID":"501","Media":[]},
+ {"ratingKey":"42","key":"/library/metadata/42","title":"Current","type":"episode","playQueueItemID":"502","Media":[]},
+ {"ratingKey":"44","key":"/library/metadata/44","title":"Second Up Next","type":"episode","playQueueItemID":"504","Media":[]},
+ {"ratingKey":"43","key":"/library/metadata/43","title":"First Up Next","type":"episode","playQueueItemID":"503","Media":[]},
+ {"ratingKey":"45","key":"/library/metadata/45","title":"Last Up Next","type":"episode","playQueueItemID":"505","Media":[]}
+ ]
+ }
+ }
+ """#.utf8)
+}
+
+private func makePlayQueueMockSession(
+ handler: @escaping @Sendable (URLRequest) throws -> (HTTPURLResponse, Data)
+) -> URLSession {
+ PlayQueueMockURLProtocol.requestHandler = handler
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.protocolClasses = [PlayQueueMockURLProtocol.self]
+ return URLSession(configuration: configuration)
+}
+
+private final class PlayQueueMockURLProtocol: URLProtocol, @unchecked Sendable {
+ nonisolated(unsafe) static var requestHandler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))?
+
+ override static func canInit(with request: URLRequest) -> Bool {
+ true
+ }
+
+ override static func canonicalRequest(for request: URLRequest) -> URLRequest {
+ request
+ }
+
+ override func startLoading() {
+ guard let handler = Self.requestHandler else {
+ client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
+ return
+ }
+
+ do {
+ let (response, data) = try handler(request)
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: data)
+ client?.urlProtocolDidFinishLoading(self)
+ } catch {
+ client?.urlProtocol(self, didFailWithError: error)
+ }
+ }
+
+ override func stopLoading() {}
+}
diff --git a/PlexBarTests/PlexPlaybackBandwidthRegistryTests.swift b/PlexBarTests/PlexPlaybackBandwidthRegistryTests.swift
new file mode 100644
index 0000000..ebcb436
--- /dev/null
+++ b/PlexBarTests/PlexPlaybackBandwidthRegistryTests.swift
@@ -0,0 +1,115 @@
+import Foundation
+import Testing
+@testable import PlexBar
+
+struct PlexPlaybackBandwidthRegistryTests {
+ @Test func newestExactSamplePersistsPerServerAcrossRegistryInstances() async throws {
+ let rootURL = temporaryRootURL()
+ defer { try? FileManager.default.removeItem(at: rootURL) }
+ let olderCandidate = makeSample(
+ byteCount: 1_000_000,
+ startedAt: 100,
+ endedAt: 102
+ )
+ let older = try #require(olderCandidate)
+ let newerCandidate = makeSample(
+ byteCount: 3_000_000,
+ startedAt: 200,
+ endedAt: 202
+ )
+ let newer = try #require(newerCandidate)
+ let registry = PlexPlaybackBandwidthRegistry(rootURL: rootURL)
+
+ try await registry.record(newer, for: "server-a")
+ try await registry.record(older, for: "server-a")
+ try await registry.record(older, for: "server-b")
+
+ let reloadedRegistry = PlexPlaybackBandwidthRegistry(rootURL: rootURL)
+ #expect(try await reloadedRegistry.record(for: "server-a") ==
+ PlexPlaybackBandwidthRecord(serverIdentifier: "server-a", sample: newer))
+ #expect(try await reloadedRegistry.record(for: "server-b") ==
+ PlexPlaybackBandwidthRecord(serverIdentifier: "server-b", sample: older))
+ #expect(try await reloadedRegistry.records().map(\.serverIdentifier) == [
+ "server-a",
+ "server-b",
+ ])
+ }
+
+ @Test func registryRetainsOnlyTheMostRecentlyMeasuredServers() async throws {
+ let rootURL = temporaryRootURL()
+ defer { try? FileManager.default.removeItem(at: rootURL) }
+ let registry = PlexPlaybackBandwidthRegistry(rootURL: rootURL)
+
+ for index in 0...PlexPlaybackBandwidthRegistry.retainedServerLimit {
+ let startedAt = TimeInterval(index * 2)
+ let endedAt = TimeInterval(index * 2 + 1)
+ let candidate = makeSample(
+ byteCount: 1_000,
+ startedAt: startedAt,
+ endedAt: endedAt
+ )
+ let bandwidthSample = try #require(candidate)
+ try await registry.record(bandwidthSample, for: "server-\(index)")
+ }
+
+ let records = try await registry.records()
+ #expect(records.count == PlexPlaybackBandwidthRegistry.retainedServerLimit)
+ #expect(!records.contains { $0.serverIdentifier == "server-0" })
+ #expect(records.first?.serverIdentifier ==
+ "server-\(PlexPlaybackBandwidthRegistry.retainedServerLimit)")
+ }
+
+ @Test func malformedRegistryFailsClosedWithoutOverwritingIt() async throws {
+ let rootURL = temporaryRootURL()
+ defer { try? FileManager.default.removeItem(at: rootURL) }
+ try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true)
+ let registryURL = rootURL.appendingPathComponent("bandwidth-history.json")
+ let originalData = Data("not-json".utf8)
+ try originalData.write(to: registryURL)
+ let registry = PlexPlaybackBandwidthRegistry(rootURL: rootURL)
+
+ await #expect(throws: PlexPlaybackBandwidthRegistryError.invalidRegistry) {
+ try await registry.records()
+ }
+ #expect(try Data(contentsOf: registryURL) == originalData)
+ }
+
+ @Test func blankServerIdentityIsRejectedBeforePersistence() async throws {
+ let rootURL = temporaryRootURL()
+ defer { try? FileManager.default.removeItem(at: rootURL) }
+ let registry = PlexPlaybackBandwidthRegistry(rootURL: rootURL)
+ let candidate = makeSample(
+ byteCount: 1_000,
+ startedAt: 0,
+ endedAt: 1
+ )
+ let bandwidthSample = try #require(candidate)
+
+ await #expect(throws: PlexPlaybackBandwidthRegistryError.invalidServerIdentifier) {
+ try await registry.record(bandwidthSample, for: " ")
+ }
+ #expect(!FileManager.default.fileExists(atPath: rootURL.path))
+ }
+
+ private func makeSample(
+ byteCount: Int64,
+ startedAt: TimeInterval,
+ endedAt: TimeInterval
+ ) -> PlexPlaybackBandwidthSample? {
+ PlexPlaybackBandwidthSample(
+ byteCount: byteCount,
+ responseStartTime: Date(timeIntervalSince1970: startedAt),
+ responseEndTime: Date(timeIntervalSince1970: endedAt),
+ wasReadFromCache: false,
+ hadError: false
+ )
+ }
+
+ private func temporaryRootURL() -> URL {
+ FileManager.default.temporaryDirectory
+ .appendingPathComponent(
+ "PlexPlaybackBandwidthRegistryTests-\(UUID().uuidString)",
+ isDirectory: true
+ )
+ }
+}
diff --git a/PlexBarTests/PlexPlaybackChapterTests.swift b/PlexBarTests/PlexPlaybackChapterTests.swift
new file mode 100644
index 0000000..0590069
--- /dev/null
+++ b/PlexBarTests/PlexPlaybackChapterTests.swift
@@ -0,0 +1,90 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+struct PlexPlaybackChapterTests {
+ @Test func metadataDecodesPlexChapterTimingAndArtwork() throws {
+ let item = try JSONDecoder().decode(PlexMediaItem.self, from: Data(#"""
+ {
+ "ratingKey": "42",
+ "title": "Episode",
+ "duration": 120000,
+ "Chapter": [{
+ "id": 81,
+ "index": "2",
+ "startTimeOffset": "30000",
+ "endTimeOffset": 90000,
+ "title": "The Chase",
+ "thumb": "/library/media/99/chapterImages/2"
+ }]
+ }
+ """#.utf8))
+
+ #expect(item.chapters.count == 1)
+ #expect(item.chapters.first?.id == "81")
+ #expect(item.chapters.first?.index == 2)
+ #expect(item.chapters.first?.startTimeOffset == 30_000)
+ #expect(item.chapters.first?.endTimeOffset == 90_000)
+ #expect(item.chapters.first?.title == "The Chase")
+ #expect(item.chapters.first?.thumb == "/library/media/99/chapterImages/2")
+ }
+
+ @Test func playbackChaptersAreSortedNamedAndClampedToMediaDuration() throws {
+ let chapters = try decodeChapters(#"""
+ [
+ {"id": 7, "index": 3, "startTimeOffset": 90000, "endTimeOffset": 140000},
+ {"id": 7, "index": 1, "startTimeOffset": 0, "endTimeOffset": 30000},
+ {
+ "id": 7,
+ "index": 2,
+ "startTimeOffset": 30000,
+ "endTimeOffset": 90000,
+ "title": " Arrival ",
+ "thumb": " /library/media/99/chapterImages/2 "
+ }
+ ]
+ """#)
+
+ let result = PlexPlaybackChapter.chapters(
+ from: chapters,
+ mediaDurationMilliseconds: 120_000
+ )
+
+ #expect(result.map(\.title) == ["Chapter 1", "Arrival", "Chapter 3"])
+ #expect(result.map(\.startTime) == [0, 30, 90])
+ #expect(result.map(\.duration) == [30, 60, 30])
+ #expect(result.map(\.thumbnailPath) == [
+ nil,
+ "/library/media/99/chapterImages/2",
+ nil
+ ])
+ #expect(Set(result.map(\.id)).count == 3)
+ }
+
+ @Test func playbackChaptersRejectInvalidAndOutOfBoundsRanges() throws {
+ let chapters = try decodeChapters(#"""
+ [
+ {"index": 1, "startTimeOffset": -1, "endTimeOffset": 10000},
+ {"index": 2, "startTimeOffset": 20000, "endTimeOffset": 20000},
+ {"index": 3, "startTimeOffset": 120000, "endTimeOffset": 140000},
+ {"index": 4, "startTimeOffset": 10000},
+ {"index": 5, "startTimeOffset": 10000, "endTimeOffset": 20000}
+ ]
+ """#)
+
+ let result = PlexPlaybackChapter.chapters(
+ from: chapters,
+ mediaDurationMilliseconds: 120_000
+ )
+
+ #expect(result.count == 1)
+ #expect(result.first?.title == "Chapter 5")
+ #expect(result.first?.startTime == 10)
+ #expect(result.first?.duration == 10)
+ }
+
+ private func decodeChapters(_ json: String) throws -> [PlexMediaChapter] {
+ try JSONDecoder().decode([PlexMediaChapter].self, from: Data(json.utf8))
+ }
+}
diff --git a/PlexBarTests/PlexPlaybackDecisionResolverTests.swift b/PlexBarTests/PlexPlaybackDecisionResolverTests.swift
new file mode 100644
index 0000000..f361066
--- /dev/null
+++ b/PlexBarTests/PlexPlaybackDecisionResolverTests.swift
@@ -0,0 +1,158 @@
+import Foundation
+import Testing
+@testable import PlexBar
+
+struct PlexPlaybackDecisionResolverTests {
+ @Test func selectsTheExactDirectPlayPartReturnedByPlex() throws {
+ let decision = try decodeDecision(directPlayDecisionData())
+
+ #expect(PlexPlaybackDecisionResolver.resolve(decision, mediaKind: .video) == .selected(
+ PlexPlaybackSelection(method: .directPlay, path: "/library/parts/7/file.mp4")
+ ))
+ }
+
+ @Test func mapsTranscodedVideoToTheNativeHLSStartEndpoint() throws {
+ let decision = try decodeDecision(transcodeDecisionData())
+
+ #expect(PlexPlaybackDecisionResolver.resolve(decision, mediaKind: .video) == .selected(
+ PlexPlaybackSelection(method: .transcode, path: "/video/:/transcode/universal/start.m3u8")
+ ))
+ }
+
+ @Test func mapsCopiedStreamsToTheNativeHLSStartEndpoint() throws {
+ let decision = try decodeDecision(Data(#"""
+ {
+ "MediaContainer": {
+ "generalDecisionCode": "1000",
+ "Metadata": [{
+ "ratingKey": "42",
+ "title": "Charade",
+ "Media": [{
+ "selected": "1",
+ "Part": [{
+ "selected": "1",
+ "decision": "copy",
+ "Stream": [{ "streamType": "1", "decision": "copy" }]
+ }]
+ }]
+ }]
+ }
+ }
+ """#.utf8))
+
+ #expect(PlexPlaybackDecisionResolver.resolve(decision, mediaKind: .video) == .selected(
+ PlexPlaybackSelection(
+ method: .directStream,
+ path: "/video/:/transcode/universal/start.m3u8"
+ )
+ ))
+ }
+
+ @Test func exposesAudioBoostOnlyForTranscodedStereoOutput() throws {
+ let decision = try decodeDecision(Data(#"""
+ {
+ "MediaContainer": {
+ "generalDecisionCode": "1000",
+ "Metadata": [{
+ "ratingKey": "42",
+ "title": "Charade",
+ "Media": [{
+ "selected": "1",
+ "Part": [{
+ "selected": "1",
+ "decision": "transcode",
+ "Stream": [
+ { "streamType": "1", "decision": "copy" },
+ { "streamType": "2", "decision": "transcode", "channels": "2" }
+ ]
+ }]
+ }]
+ }]
+ }
+ }
+ """#.utf8))
+
+ #expect(PlexPlaybackDecisionResolver.resolve(decision, mediaKind: .video) == .selected(
+ PlexPlaybackSelection(
+ method: .transcode,
+ path: "/video/:/transcode/universal/start.m3u8",
+ supportsAudioBoost: true
+ )
+ ))
+ }
+
+ @Test(arguments: [
+ (6, "transcode"),
+ (2, "copy"),
+ ])
+ func hidesAudioBoostWhenOutputIsNotStereoOrAudioIsNotTranscoded(
+ channels: Int,
+ audioDecision: String
+ ) throws {
+ let decision = try decodeDecision(Data(#"""
+ {
+ "MediaContainer": {
+ "generalDecisionCode": "1000",
+ "Metadata": [{
+ "ratingKey": "42",
+ "title": "Charade",
+ "Media": [{
+ "selected": "1",
+ "Part": [{
+ "selected": "1",
+ "decision": "transcode",
+ "Stream": [
+ { "streamType": "1", "decision": "transcode" },
+ {
+ "streamType": "2",
+ "decision": "\#(audioDecision)",
+ "channels": "\#(channels)"
+ }
+ ]
+ }]
+ }]
+ }]
+ }
+ }
+ """#.utf8))
+
+ let resolution = PlexPlaybackDecisionResolver.resolve(decision, mediaKind: .video)
+ guard case .selected(let selection) = resolution else {
+ Issue.record("Expected a playable selection")
+ return
+ }
+ #expect(!selection.supportsAudioBoost)
+ }
+
+ @Test func preservesTheServerPlaybackRejectionReason() throws {
+ let decision = try decodeDecision(Data(#"""
+ {
+ "MediaContainer": {
+ "generalDecisionCode": "2000",
+ "generalDecisionText": "Playback is not possible for this item."
+ }
+ }
+ """#.utf8))
+
+ #expect(PlexPlaybackDecisionResolver.resolve(decision, mediaKind: .video) == .rejected(
+ "Playback is not possible for this item."
+ ))
+ }
+
+ @Test func reportsMissingPlayableMediaWhenTheDecisionHasNoPart() throws {
+ let decision = try decodeDecision(Data(#"""
+ {
+ "MediaContainer": {
+ "generalDecisionCode": "1000",
+ "Metadata": []
+ }
+ }
+ """#.utf8))
+
+ #expect(PlexPlaybackDecisionResolver.resolve(decision, mediaKind: .video) == .noPlayableMedia)
+ }
+
+ private func decodeDecision(_ data: Data) throws -> PlexPlaybackDecisionContainer {
+ try JSONDecoder().decode(PlexPlaybackDecisionEnvelope.self, from: data).mediaContainer
+ }
+}
diff --git a/PlexBarTests/PlexPlaybackEngineTests.swift b/PlexBarTests/PlexPlaybackEngineTests.swift
new file mode 100644
index 0000000..889f2b2
--- /dev/null
+++ b/PlexBarTests/PlexPlaybackEngineTests.swift
@@ -0,0 +1,1408 @@
+import PlexModels
+import AppKit
+import AVFoundation
+import AVKit
+import Foundation
+import SwiftUI
+import Testing
+@testable import PlexBar
+
+@MainActor
+struct PlexPlaybackEngineTests {
+ @Test func videoDynamicRangeMapsExactlyToTheMacOS26AVKitPolicy() {
+ #expect(PlexVideoDisplayDynamicRange.automatic.avDisplayDynamicRange == .automatic)
+ #expect(PlexVideoDisplayDynamicRange.standard.avDisplayDynamicRange == .standard)
+ #expect(PlexVideoDisplayDynamicRange.constrainedHigh.avDisplayDynamicRange == .constrainedHigh)
+ #expect(PlexVideoDisplayDynamicRange.high.avDisplayDynamicRange == .high)
+ #expect(PlexVideoDisplayDynamicRange.allCases.map(\.label) == [
+ "Automatic",
+ "Standard Dynamic Range",
+ "Constrained High Dynamic Range",
+ "High Dynamic Range",
+ ])
+ }
+
+ @Test func videoScalingPreservesAspectRatioForFitAndFill() {
+ #expect(PlexVideoScalingMode.fit.avVideoGravity == .resizeAspect)
+ #expect(PlexVideoScalingMode.fill.avVideoGravity == .resizeAspectFill)
+ #expect(PlexVideoScalingMode.allCases.map(\.label) == ["Fit", "Fill"])
+
+ let playerView = AVPlayerView()
+ PlexNativeVideoScalingConfiguration.apply(to: playerView, scalingMode: .fill)
+ #expect(playerView.videoGravity == .resizeAspectFill)
+
+ PlexNativeVideoScalingConfiguration.apply(to: playerView, scalingMode: .fit)
+ #expect(playerView.videoGravity == .resizeAspect)
+ }
+
+ @Test func nativeWaitingReasonsMapExactlyAndTransientEvaluationStaysOutOfUI() {
+ #expect(waitingReason(.toMinimizeStalls) == .minimizingStalls)
+ #expect(waitingReason(.toMinimizeStalls)?.diagnosticLabel == "Minimizing Stalls")
+ #expect(waitingReason(.noItemToPlay) == .noItemToPlay)
+ #expect(waitingReason(.noItemToPlay)?.diagnosticLabel == "No Item to Play")
+ #expect(
+ waitingReason(.waitingForCoordinatedPlayback) == .coordinatedPlayback
+ )
+ #expect(
+ waitingReason(.waitingForCoordinatedPlayback)?.diagnosticLabel
+ == "Coordinated Playback"
+ )
+ #expect(waitingReason(.interstitialEvent) == .interstitialEvent)
+ #expect(waitingReason(.interstitialEvent)?.diagnosticLabel == "Interstitial Event")
+
+ let evaluating = waitingReason(.evaluatingBufferingRate)
+ #expect(evaluating == .evaluatingBufferingRate)
+ #expect(evaluating?.diagnosticLabel == nil)
+
+ let futureReason = AVPlayer.WaitingReason(rawValue: "Future AVPlayer waiting reason")
+ #expect(waitingReason(futureReason) == nil)
+ #expect(waitingReason(nil) == nil)
+ #expect(PlexPlaybackWaitingReason(
+ timeControlStatus: .playing,
+ nativeReason: .toMinimizeStalls
+ ) == nil)
+ #expect(PlexPlaybackWaitingReason(
+ timeControlStatus: .paused,
+ nativeReason: .noItemToPlay
+ ) == nil)
+ }
+
+ @Test func playerNavigationTitleUsesTheCurrentMediaTitleAndAnEmptyFallback() {
+ #expect(PlexPlayerNavigationTitle.resolve("Arrival") == "Arrival")
+ #expect(PlexPlayerNavigationTitle.resolve(" The Bear\n") == "The Bear")
+ #expect(PlexPlayerNavigationTitle.resolve(" \n\t ") == "Player")
+ #expect(PlexPlayerNavigationTitle.resolve(nil) == "Player")
+ }
+
+ private func waitingReason(
+ _ nativeReason: AVPlayer.WaitingReason?
+ ) -> PlexPlaybackWaitingReason? {
+ PlexPlaybackWaitingReason(
+ timeControlStatus: .waitingToPlayAtSpecifiedRate,
+ nativeReason: nativeReason
+ )
+ }
+
+ @Test func endNotificationIsTheAuthoritativeCompletionSignal() async throws {
+ let engine = PlexPlaybackEngine()
+ let plan = PlexPlaybackPlan(
+ url: URL(fileURLWithPath: "/tmp/plexbar-end-notification-test.mp4"),
+ method: .directPlay,
+ mediaKind: .video,
+ sessionIdentifier: "session-1",
+ ratingKey: "42",
+ duration: 120,
+ startTime: 0,
+ source: PlexPlaybackSource(mediaIndex: 0, partIndex: 0),
+ usesServerMediaSelection: false
+ )
+
+ try await engine.load(plan: plan)
+ await Task.yield()
+ let item = try #require(engine.player.currentItem)
+ NotificationCenter.default.post(
+ name: AVPlayerItem.didPlayToEndTimeNotification,
+ object: item
+ )
+
+ for _ in 0..<20 where engine.status != .ended {
+ try await Task.sleep(for: .milliseconds(10))
+ }
+
+ #expect(engine.status == .ended)
+ #expect(engine.position == 120)
+ engine.stop()
+ }
+
+ @Test func unexpectedTimeJumpsBelongOnlyToTheExactCurrentItem() async throws {
+ let engine = PlexPlaybackEngine()
+ let firstPlan = playbackPlan(
+ sessionIdentifier: "time-jump-first",
+ ratingKey: "first"
+ )
+ let secondPlan = playbackPlan(
+ sessionIdentifier: "time-jump-second",
+ ratingKey: "second"
+ )
+
+ try await engine.load(plan: firstPlan, autoplay: false)
+ await Task.yield()
+ let firstItem = try #require(engine.player.currentItem)
+
+ try await engine.load(plan: secondPlan, autoplay: false)
+ await Task.yield()
+ let secondItem = try #require(engine.player.currentItem)
+
+ NotificationCenter.default.post(
+ name: AVPlayerItem.timeJumpedNotification,
+ object: firstItem
+ )
+ try await Task.sleep(for: .milliseconds(20))
+ #expect(engine.unexpectedTimeJumpRevision == 0)
+
+ NotificationCenter.default.post(
+ name: AVPlayerItem.timeJumpedNotification,
+ object: secondItem
+ )
+ for _ in 0..<20 where engine.unexpectedTimeJumpRevision == 0 {
+ try await Task.sleep(for: .milliseconds(10))
+ }
+ #expect(engine.unexpectedTimeJumpRevision == 1)
+
+ engine.stop()
+ NotificationCenter.default.post(
+ name: AVPlayerItem.timeJumpedNotification,
+ object: secondItem
+ )
+ try await Task.sleep(for: .milliseconds(20))
+ #expect(engine.unexpectedTimeJumpRevision == 1)
+ }
+
+ @Test func ownedTimeJumpExpectationsAreBoundedAndConsumedExactlyOnce() {
+ var expectations = PlexPlaybackTimeJumpExpectations()
+ let first = expectations.expect(target: 42)
+
+ #expect(first != nil)
+ #expect(expectations.retainedCount == 1)
+ let rejectedDistantPosition = expectations.consume(position: 41.7)
+ let consumedExpectedPosition = expectations.consume(position: 42.2)
+ let rejectedDuplicatePosition = expectations.consume(position: 42.2)
+ #expect(!rejectedDistantPosition)
+ #expect(consumedExpectedPosition)
+ #expect(!rejectedDuplicatePosition)
+ #expect(expectations.retainedCount == 0)
+
+ let cancelled = expectations.expect(target: 80)
+ expectations.cancel(cancelled)
+ let consumedCancelledPosition = expectations.consume(position: 80)
+ #expect(!consumedCancelledPosition)
+
+ let infiniteTarget = expectations.expect(target: -.infinity)
+ let negativeTarget = expectations.expect(target: -1)
+ #expect(infiniteTarget == nil)
+ #expect(negativeTarget == nil)
+
+ for target in 0..<(PlexPlaybackTimeJumpExpectations.maximumRetainedCount + 3) {
+ _ = expectations.expect(target: TimeInterval(target))
+ }
+ #expect(
+ expectations.retainedCount
+ == PlexPlaybackTimeJumpExpectations.maximumRetainedCount
+ )
+
+ expectations.invalidate()
+ #expect(expectations.retainedCount == 0)
+ }
+
+ @Test func displaySleepPreventionFollowsSelectedMediaKindAcrossLoadsAndStop() async throws {
+ let engine = PlexPlaybackEngine()
+ let videoPlan = PlexPlaybackPlan(
+ url: URL(fileURLWithPath: "/tmp/plexbar-display-sleep-video.mp4"),
+ method: .directPlay,
+ mediaKind: .video,
+ sessionIdentifier: "video-session",
+ ratingKey: "video-42",
+ duration: 120,
+ startTime: 0,
+ source: PlexPlaybackSource(mediaIndex: 0, partIndex: 0),
+ usesServerMediaSelection: false
+ )
+ let musicPlan = PlexPlaybackPlan(
+ url: URL(fileURLWithPath: "/tmp/plexbar-display-sleep-music.m4a"),
+ method: .directPlay,
+ mediaKind: .music,
+ sessionIdentifier: "music-session",
+ ratingKey: "music-42",
+ duration: 180,
+ startTime: 0,
+ source: PlexPlaybackSource(mediaIndex: 0, partIndex: 0),
+ usesServerMediaSelection: false
+ )
+
+ #expect(!engine.player.preventsDisplaySleepDuringVideoPlayback)
+
+ try await engine.load(plan: videoPlan, autoplay: false)
+ #expect(engine.player.preventsDisplaySleepDuringVideoPlayback)
+
+ try await engine.load(plan: musicPlan, autoplay: false)
+ #expect(!engine.player.preventsDisplaySleepDuringVideoPlayback)
+
+ try await engine.load(plan: videoPlan, autoplay: false)
+ #expect(engine.player.preventsDisplaySleepDuringVideoPlayback)
+
+ engine.stop()
+ #expect(!engine.player.preventsDisplaySleepDuringVideoPlayback)
+ }
+
+ @Test func coordinatorCloseStopsTheNativePlayerSynchronously() async throws {
+ let itemData = Data(#"{"ratingKey":"42","title":"Stop Test","type":"movie","Media":[]}"#.utf8)
+ let item = try JSONDecoder().decode(PlexMediaItem.self, from: itemData)
+ let plan = PlexPlaybackPlan(
+ url: URL(fileURLWithPath: "/tmp/plexbar-synchronous-stop-test.mp4"),
+ method: .directPlay,
+ mediaKind: .video,
+ sessionIdentifier: "synchronous-stop-session",
+ ratingKey: item.ratingKey,
+ duration: 120,
+ startTime: 0,
+ source: PlexPlaybackSource(mediaIndex: 0, partIndex: 0),
+ usesServerMediaSelection: false
+ )
+ let presentation = PlexPlaybackPresentation(
+ item: item,
+ plan: plan,
+ queue: nil,
+ videoQuality: .original
+ )
+ let defaultsName = "PlexPlaybackEngineTests.\(UUID().uuidString)"
+ let defaults = try #require(UserDefaults(suiteName: defaultsName))
+ defer { defaults.removePersistentDomain(forName: defaultsName) }
+ let credentials = PlexStoredCredentials(userToken: "", serverToken: "")
+ let settings = PlexSettingsStore(
+ defaults: defaults,
+ credentialStore: PlexMemoryCredentialStore(credentials: credentials),
+ initialCredentials: credentials
+ )
+ let browserStore = PlexBrowserStore(
+ connectionStore: PlexConnectionStore(settings: settings),
+ playbackCapabilities: PlexPlaybackCapabilities(
+ directPlayContainers: [],
+ directPlayVideoCodecs: [],
+ directPlayAudioCodecs: []
+ )
+ )
+ let coordinator = PlexPlayerCoordinator()
+ coordinator.present(presentation)
+ let session = coordinator.session(
+ for: presentation,
+ browserStore: browserStore
+ )
+
+ try await session.engine.load(plan: plan, autoplay: false)
+ #expect(session.engine.player.currentItem != nil)
+
+ coordinator.close(session)
+
+ #expect(session.engine.player.currentItem == nil)
+ #expect(session.engine.status == .idle)
+ #expect(!session.engine.player.preventsDisplaySleepDuringVideoPlayback)
+ }
+
+ @Test func coordinatorPublishesOnlyItsActiveSessionsExactCurrentItem() throws {
+ let firstItem = try JSONDecoder().decode(
+ PlexMediaItem.self,
+ from: Data(
+ #"{"ratingKey":"41","title":"First","type":"movie","Media":[]}"#.utf8
+ )
+ )
+ let secondItem = try JSONDecoder().decode(
+ PlexMediaItem.self,
+ from: Data(
+ #"{"ratingKey":"42","title":"Second","type":"movie","Media":[{"Part":[{"id":"700","Stream":[{"id":"21","streamType":"2","language":"English","selected":"1"},{"id":"22","streamType":"2","language":"French"}]}]}]}"#.utf8
+ )
+ )
+ let refreshedSecondItem = try JSONDecoder().decode(
+ PlexMediaItem.self,
+ from: Data(
+ #"{"ratingKey":"42","title":"Second Updated","type":"movie","Media":[]}"#.utf8
+ )
+ )
+ let firstPresentation = PlexPlaybackPresentation(
+ item: firstItem,
+ plan: playbackPlan(
+ sessionIdentifier: "first-session",
+ ratingKey: firstItem.ratingKey
+ ),
+ queue: nil,
+ videoQuality: .original,
+ serverIdentifier: " server-a\n"
+ )
+ let secondPresentation = PlexPlaybackPresentation(
+ item: secondItem,
+ plan: playbackPlan(
+ sessionIdentifier: "second-session",
+ ratingKey: secondItem.ratingKey
+ ),
+ queue: nil,
+ videoQuality: .original,
+ serverIdentifier: "server-b"
+ )
+ let refreshedSecondPresentation = PlexPlaybackPresentation(
+ item: refreshedSecondItem,
+ plan: secondPresentation.plan,
+ queue: nil,
+ videoQuality: .original,
+ serverIdentifier: secondPresentation.serverIdentifier
+ )
+ let defaultsName = "PlexPlaybackEngineTests.\(UUID().uuidString)"
+ let defaults = try #require(UserDefaults(suiteName: defaultsName))
+ defer { defaults.removePersistentDomain(forName: defaultsName) }
+ let credentials = PlexStoredCredentials(userToken: "", serverToken: "")
+ let settings = PlexSettingsStore(
+ defaults: defaults,
+ credentialStore: PlexMemoryCredentialStore(credentials: credentials),
+ initialCredentials: credentials
+ )
+ let browserStore = PlexBrowserStore(
+ connectionStore: PlexConnectionStore(settings: settings),
+ playbackCapabilities: PlexPlaybackCapabilities(
+ directPlayContainers: [],
+ directPlayVideoCodecs: [],
+ directPlayAudioCodecs: []
+ )
+ )
+ let coordinator = PlexPlayerCoordinator()
+
+ #expect(coordinator.currentPlayback == nil)
+ coordinator.present(firstPresentation)
+ let firstSession = coordinator.session(
+ for: firstPresentation,
+ browserStore: browserStore
+ )
+ #expect(coordinator.currentPlayback?.item == firstItem)
+ #expect(coordinator.currentPlayback?.serverIdentifier == "server-a")
+ #expect(coordinator.currentPlayback?.belongs(to: "server-a") == true)
+ #expect(coordinator.currentPlayback?.belongs(to: "server-b") == false)
+
+ coordinator.present(secondPresentation)
+ let secondSession = coordinator.session(
+ for: secondPresentation,
+ browserStore: browserStore
+ )
+ #expect(coordinator.currentPlayback?.item == secondItem)
+
+ coordinator.installTransport(
+ for: secondSession,
+ status: .paused,
+ canToggle: true,
+ toggle: {},
+ stop: {}
+ )
+ coordinator.installServerManagedMediaSelection(
+ for: secondSession,
+ selection: PlexServerManagedMediaSelection(
+ selection: PlexPlaybackMediaSelection(
+ item: secondItem,
+ source: secondPresentation.plan.source
+ ),
+ nativeAvailability: PlexNativeMediaSelectionAvailability(
+ audioOptionCount: 0,
+ subtitleOptionCount: 0
+ )
+ ),
+ canChange: true,
+ selectAudioStream: { _ in },
+ selectSubtitleStream: { _ in }
+ )
+ coordinator.updateQueueInsertion(
+ for: secondSession,
+ canAdd: true,
+ isAdding: false
+ )
+ #expect(coordinator.canStop)
+ #expect(coordinator.canChangeServerManagedMediaSelection)
+ #expect(coordinator.canAddItemsToQueue)
+
+ coordinator.clearPlaybackControls(for: firstSession)
+ #expect(coordinator.canStop)
+ #expect(coordinator.canChangeServerManagedMediaSelection)
+ #expect(coordinator.canAddItemsToQueue)
+
+ coordinator.clearPlaybackControls(for: secondSession)
+ #expect(!coordinator.canStop)
+ #expect(!coordinator.canChangeServerManagedMediaSelection)
+ #expect(!coordinator.serverManagedMediaSelection.hasChoices)
+ #expect(!coordinator.canAddItemsToQueue)
+ #expect(!coordinator.isAddingToQueue)
+
+ coordinator.updateCurrentPlayback(
+ for: firstSession,
+ presentation: firstPresentation
+ )
+ #expect(coordinator.currentPlayback?.item == secondItem)
+
+ coordinator.updateCurrentPlayback(
+ for: secondSession,
+ presentation: refreshedSecondPresentation
+ )
+ #expect(coordinator.currentPlayback?.item == refreshedSecondItem)
+ #expect(coordinator.presentation?.item == secondItem)
+ #expect(coordinator.presentation?.id == "second-session")
+
+ coordinator.close(secondSession)
+ #expect(coordinator.currentPlayback == nil)
+ }
+
+ @Test func routePickerBindsTheExactPlayerAndRetainsItsAccessibleIdentity() {
+ let routePickerView = AVRoutePickerView()
+ let videoPlayer = AVPlayer()
+ PlexPlaybackRoutePickerConfiguration.apply(
+ to: routePickerView,
+ player: videoPlayer
+ )
+
+ #expect(routePickerView.player === videoPlayer)
+ #expect(routePickerView.accessibilityLabel()?.nilIfBlank != nil)
+
+ let musicPlayer = AVPlayer()
+ PlexPlaybackRoutePickerConfiguration.apply(
+ to: routePickerView,
+ player: musicPlayer
+ )
+
+ #expect(routePickerView.player === musicPlayer)
+ }
+
+ @Test func videoPreparationStageCoversOnlyThePrePlaybackVideoInterval() {
+ #expect(PlexVideoPreparationPolicy.shouldPresent(
+ mediaKind: .video,
+ status: .idle,
+ isLoading: true
+ ))
+ #expect(!PlexVideoPreparationPolicy.shouldPresent(
+ mediaKind: .video,
+ status: .idle,
+ isLoading: false
+ ))
+ #expect(PlexVideoPreparationPolicy.shouldPresent(
+ mediaKind: .video,
+ status: .preparing,
+ isLoading: false
+ ))
+
+ for status in [
+ PlexPlaybackStatus.playing,
+ .paused,
+ .buffering,
+ .ended,
+ .failed("Unavailable"),
+ ] {
+ #expect(!PlexVideoPreparationPolicy.shouldPresent(
+ mediaKind: .video,
+ status: status,
+ isLoading: true
+ ))
+ }
+
+ #expect(!PlexVideoPreparationPolicy.shouldPresent(
+ mediaKind: .music,
+ status: .preparing,
+ isLoading: true
+ ))
+
+ }
+
+ @Test func nativePlayerSizingAcceptsOnlyFinitePositiveProposals() {
+ #expect(PlexNativePlayerSizing.exactSize(for: ProposedViewSize(
+ width: 1_000,
+ height: 580
+ )) == CGSize(width: 1_000, height: 580))
+ #expect(PlexNativePlayerSizing.exactSize(for: ProposedViewSize(
+ width: 1_000,
+ height: nil
+ )) == nil)
+ #expect(PlexNativePlayerSizing.exactSize(for: ProposedViewSize(
+ width: .infinity,
+ height: 580
+ )) == nil)
+ #expect(PlexNativePlayerSizing.exactSize(for: ProposedViewSize(
+ width: 0,
+ height: 580
+ )) == nil)
+ }
+
+ @Test func nativePlayerStageClaimsTheEntireAvailableContentRegion() {
+ let recorder = PlexPlayerStageSizeRecorder()
+ let hostingView = NSHostingView(rootView: VStack(spacing: 0) {
+ PlexPlayerStage {
+ PlexPlayerStageSizeProbe(recorder: recorder)
+ }
+ Color.clear
+ .frame(height: 60)
+ })
+ hostingView.frame = CGRect(x: 0, y: 0, width: 1_000, height: 650)
+
+ hostingView.layoutSubtreeIfNeeded()
+
+ #expect(abs(recorder.size.width - 1_000) < 0.5)
+ #expect(abs(recorder.size.height - 590) < 0.5)
+ }
+
+ @Test func coordinatorRunsOnlyAvailableNativeNavigationActions() {
+ let coordinator = PlexPlayerCoordinator()
+ var previousCount = 0
+ var nextCount = 0
+ coordinator.installNavigation(
+ previous: { previousCount += 1 },
+ next: { nextCount += 1 }
+ )
+
+ coordinator.goPrevious()
+ coordinator.goNext()
+ #expect(previousCount == 0)
+ #expect(nextCount == 0)
+
+ coordinator.updateNavigation(canGoPrevious: true, canGoNext: true)
+ coordinator.goPrevious()
+ coordinator.goNext()
+ #expect(previousCount == 1)
+ #expect(nextCount == 1)
+
+ coordinator.clearNavigation()
+ #expect(!coordinator.canGoPrevious)
+ #expect(!coordinator.canGoNext)
+ }
+
+ @Test func seekPolicyUsesPlexTenSecondIntervalsAndClampsToMediaBounds() {
+ #expect(PlexPlaybackSeek.skipInterval == 10)
+ #expect(PlexPlaybackSkipDirection.backward.offset(for: 10) == -10)
+ #expect(PlexPlaybackSkipDirection.forward.offset(for: 10) == 10)
+ #expect(PlexPlaybackSkipDirection.forward.offset(for: 0) == nil)
+ #expect(PlexPlaybackSkipDirection.backward.offset(for: .infinity) == nil)
+
+ #expect(PlexPlaybackSeek.target(from: 5, duration: 120, offset: -10) == 0)
+ #expect(PlexPlaybackSeek.target(from: 115, duration: 120, offset: 10) == 120)
+ #expect(PlexPlaybackSeek.target(from: 40, duration: 120, offset: -10) == 30)
+ #expect(PlexPlaybackSeek.target(from: 40, duration: 120, offset: 10) == 50)
+ #expect(PlexPlaybackSeek.target(from: 40, duration: nil, offset: 10) == 50)
+ #expect(PlexPlaybackSeek.clamped(-5, duration: 120) == 0)
+ #expect(PlexPlaybackSeek.clamped(125, duration: 120) == 120)
+ #expect(PlexPlaybackSeek.clamped(.nan, duration: 120) == 0)
+ }
+
+ @Test func rapidRelativeSeekReservationsAccumulateAndOnlyLatestCompletionWins() {
+ var sequence = PlexPlaybackSeekSequence()
+
+ let first = sequence.reserve(
+ relativeOffset: 10,
+ currentPosition: 40,
+ duration: 120
+ )
+ let second = sequence.reserve(
+ relativeOffset: 10,
+ currentPosition: 40,
+ duration: 120
+ )
+ let third = sequence.reserve(
+ relativeOffset: -10,
+ currentPosition: 40,
+ duration: 120
+ )
+
+ #expect(first.target == 50)
+ #expect(second.target == 60)
+ #expect(third.target == 50)
+ #expect(sequence.pendingTarget == 50)
+ let firstDidFinish = sequence.finish(first)
+ let secondDidFinish = sequence.finish(second)
+ #expect(!firstDidFinish)
+ #expect(!secondDidFinish)
+ #expect(sequence.pendingTarget == 50)
+ let thirdDidFinish = sequence.finish(third)
+ #expect(thirdDidFinish)
+ #expect(sequence.pendingTarget == nil)
+ }
+
+ @Test func absoluteSeekAndInvalidationRejectStaleCompletions() {
+ var sequence = PlexPlaybackSeekSequence()
+ let relative = sequence.reserve(
+ relativeOffset: 10,
+ currentPosition: 40,
+ duration: 120
+ )
+ let absolute = sequence.reserve(
+ absoluteTarget: 500,
+ duration: 120
+ )
+
+ #expect(absolute.target == 120)
+ let relativeDidFinish = sequence.finish(relative)
+ #expect(!relativeDidFinish)
+
+ sequence.invalidate()
+
+ let absoluteDidFinish = sequence.finish(absolute)
+ #expect(!absoluteDidFinish)
+ #expect(sequence.pendingTarget == nil)
+ }
+
+ @Test func stoppedSessionEpochRejectsEveryOutstandingPlaybackOperation() {
+ var epoch = PlexPlaybackSessionEpoch()
+
+ #expect(epoch.currentTicket() == nil)
+ let playback = epoch.activate()
+ let queueTransition = epoch.currentTicket()
+
+ #expect(epoch.isCurrent(playback))
+ #expect(queueTransition.map(epoch.isCurrent) == true)
+
+ epoch.invalidate()
+
+ #expect(!epoch.isActive)
+ #expect(!epoch.isCurrent(playback))
+ #expect(queueTransition.map(epoch.isCurrent) == false)
+ #expect(epoch.currentTicket() == nil)
+ }
+
+ @Test func restartedSessionEpochCannotReviveAnOperationFromThePriorSession() {
+ var epoch = PlexPlaybackSessionEpoch()
+ let priorSessionOperation = epoch.activate()
+
+ epoch.invalidate()
+ let restartedSessionOperation = epoch.activate()
+
+ #expect(!epoch.isCurrent(priorSessionOperation))
+ #expect(epoch.isCurrent(restartedSessionOperation))
+ #expect(epoch.currentTicket() == restartedSessionOperation)
+ }
+
+ @Test func timelineResponseIdentityRequiresTheExactPlaybackSessionAndEpoch() {
+ var epoch = PlexPlaybackSessionEpoch()
+ let ticket = epoch.activate()
+ let identity = PlexTimelineRequestIdentity(
+ sessionIdentifier: "session-a",
+ ticket: ticket
+ )
+
+ #expect(identity.isCurrent(sessionIdentifier: "session-a", epoch: epoch))
+ #expect(!identity.isCurrent(sessionIdentifier: "session-b", epoch: epoch))
+
+ epoch.invalidate()
+
+ #expect(!identity.isCurrent(sessionIdentifier: "session-a", epoch: epoch))
+ }
+
+ @Test func timelineResponseFromThePriorEpochCannotAffectARestartedSession() {
+ var epoch = PlexPlaybackSessionEpoch()
+ let priorIdentity = PlexTimelineRequestIdentity(
+ sessionIdentifier: "reused-session",
+ ticket: epoch.activate()
+ )
+
+ epoch.invalidate()
+ let restartedIdentity = PlexTimelineRequestIdentity(
+ sessionIdentifier: "reused-session",
+ ticket: epoch.activate()
+ )
+
+ #expect(!priorIdentity.isCurrent(sessionIdentifier: "reused-session", epoch: epoch))
+ #expect(restartedIdentity.isCurrent(sessionIdentifier: "reused-session", epoch: epoch))
+ }
+
+ @Test func timelineReporterSerializesRequestsInSubmissionOrder() async throws {
+ var events: [String] = []
+ var releaseFirstReport: CheckedContinuation?
+ let reporter = PlexTimelineReportSequencer { update in
+ events.append("start-\(update.time)")
+ if update.time == 1_000 {
+ await withCheckedContinuation { continuation in
+ releaseFirstReport = continuation
+ }
+ }
+ events.append("finish-\(update.time)")
+ return nil
+ }
+ let firstUpdate = PlexTimelineUpdate(
+ ratingKey: "42",
+ state: .playing,
+ time: 1_000,
+ duration: 10_000,
+ sessionIdentifier: "session-a"
+ )
+ let secondUpdate = PlexTimelineUpdate(
+ ratingKey: "42",
+ state: .stopped,
+ time: 2_000,
+ duration: 10_000,
+ sessionIdentifier: "session-a"
+ )
+
+ let first = Task { await reporter.report(firstUpdate) }
+ for _ in 0..<20 where releaseFirstReport == nil {
+ await Task.yield()
+ }
+ let firstReportContinuation = try #require(releaseFirstReport)
+
+ let second = Task { await reporter.report(secondUpdate) }
+ for _ in 0..<20 {
+ await Task.yield()
+ }
+
+ #expect(events == ["start-1000"])
+
+ firstReportContinuation.resume()
+ _ = await (first.value, second.value)
+
+ #expect(events == [
+ "start-1000",
+ "finish-1000",
+ "start-2000",
+ "finish-2000",
+ ])
+ }
+
+ @Test func timelineReporterPreservesFinalStopAfterPeriodicCallerCancellation() async throws {
+ var events: [String] = []
+ var releasePlayingReport: CheckedContinuation?
+ let reporter = PlexTimelineReportSequencer { update in
+ events.append("start-\(update.state.rawValue)")
+ if update.state == .playing {
+ await withCheckedContinuation { continuation in
+ releasePlayingReport = continuation
+ }
+ }
+ events.append("finish-\(update.state.rawValue)")
+ return nil
+ }
+ let playingUpdate = PlexTimelineUpdate(
+ ratingKey: "42",
+ state: .playing,
+ time: 1_000,
+ duration: 10_000,
+ sessionIdentifier: "session-a"
+ )
+ let stoppedUpdate = PlexTimelineUpdate(
+ ratingKey: "42",
+ state: .stopped,
+ time: 2_000,
+ duration: 10_000,
+ sessionIdentifier: "session-a"
+ )
+
+ let periodicCaller = Task { await reporter.report(playingUpdate) }
+ for _ in 0..<20 where releasePlayingReport == nil {
+ await Task.yield()
+ }
+ let playingReportContinuation = try #require(releasePlayingReport)
+
+ periodicCaller.cancel()
+ let finalStopCaller = Task { await reporter.report(stoppedUpdate) }
+ for _ in 0..<20 {
+ await Task.yield()
+ }
+
+ #expect(events == ["start-playing"])
+
+ playingReportContinuation.resume()
+ _ = await (periodicCaller.value, finalStopCaller.value)
+
+ #expect(events == [
+ "start-playing",
+ "finish-playing",
+ "start-stopped",
+ "finish-stopped",
+ ])
+ }
+
+ @Test func coordinatorRunsSeekCommandsOnlyForTheActiveSeekOwner() {
+ let coordinator = PlexPlayerCoordinator()
+ var offsets: [TimeInterval] = []
+
+ coordinator.installSeeking(canSeek: false) { offset in
+ offsets.append(offset)
+ }
+ coordinator.skipBackward()
+ coordinator.skipForward()
+ #expect(offsets.isEmpty)
+
+ coordinator.updateSeeking(canSeek: true)
+ coordinator.skipBackward()
+ coordinator.skipForward()
+ #expect(offsets == [-10, 10])
+
+ coordinator.clearSeeking()
+ coordinator.skipForward()
+ #expect(!coordinator.canSeek)
+ #expect(offsets == [-10, 10])
+ }
+
+ @Test func transportCommandsFollowTheActivePlaybackStateAndOwner() {
+ let coordinator = PlexPlayerCoordinator()
+ var toggleCount = 0
+ var stopCount = 0
+
+ coordinator.togglePlayback()
+ coordinator.stopPlayback()
+ #expect(coordinator.transportAction == nil)
+ #expect(!coordinator.canStop)
+ #expect(coordinator.playbackStatus == .idle)
+
+ coordinator.installTransport(
+ status: .paused,
+ toggle: { toggleCount += 1 },
+ stop: { stopCount += 1 }
+ )
+ #expect(coordinator.transportAction == .play)
+ #expect(coordinator.canStop)
+ #expect(coordinator.playbackStatus == .paused)
+
+ coordinator.togglePlayback()
+ #expect(toggleCount == 1)
+
+ coordinator.updateTransport(status: .buffering)
+ #expect(coordinator.transportAction == .pause)
+ #expect(coordinator.playbackStatus == .buffering)
+ coordinator.togglePlayback()
+ #expect(toggleCount == 2)
+
+ coordinator.updateTransport(status: .playing, canToggle: false)
+ #expect(coordinator.playbackStatus == .playing)
+ coordinator.togglePlayback()
+ coordinator.stopPlayback()
+ #expect(coordinator.transportAction == nil)
+ #expect(toggleCount == 2)
+ #expect(stopCount == 1)
+
+ coordinator.clearTransport()
+ coordinator.stopPlayback()
+ #expect(!coordinator.canStop)
+ #expect(coordinator.playbackStatus == .idle)
+ #expect(stopCount == 1)
+ }
+
+ @Test func transportActionTreatsPreparationAndBufferingAsPauseableIntent() {
+ #expect(PlexPlaybackTransportAction(status: .idle) == nil)
+ #expect(PlexPlaybackTransportAction(status: .preparing) == .pause)
+ #expect(PlexPlaybackTransportAction(status: .playing) == .pause)
+ #expect(PlexPlaybackTransportAction(status: .buffering) == .pause)
+ #expect(PlexPlaybackTransportAction(status: .paused) == .play)
+ #expect(PlexPlaybackTransportAction(status: .ended) == nil)
+ #expect(PlexPlaybackTransportAction(status: .failed("failed")) == nil)
+ }
+
+ @Test func bareSpaceShortcutBelongsOnlyToTheFocusedPlayerSurface() {
+ #expect(PlexPlaybackKeyboardShortcut.playPause(isPlayerSurfaceFocused: false) == nil)
+
+ let shortcut = PlexPlaybackKeyboardShortcut.playPause(isPlayerSurfaceFocused: true)
+ #expect(shortcut?.key.character == KeyEquivalent.space.character)
+ #expect(shortcut?.modifiers.isEmpty == true)
+ }
+
+ @Test func playbackRateSelectionRequiresAnActiveOwner() {
+ let coordinator = PlexPlayerCoordinator()
+ var selections: [PlexPlaybackRate] = []
+
+ coordinator.selectPlaybackRate(.double)
+ #expect(selections.isEmpty)
+
+ coordinator.installPlaybackRate(playbackRate: .normal) {
+ selections.append($0)
+ }
+ #expect(coordinator.canChangePlaybackRate)
+ #expect(coordinator.playbackRate == .normal)
+
+ coordinator.selectPlaybackRate(.oneAndAHalf)
+ #expect(selections == [.oneAndAHalf])
+
+ coordinator.clearPlaybackRate()
+ coordinator.selectPlaybackRate(.double)
+ #expect(!coordinator.canChangePlaybackRate)
+ #expect(coordinator.playbackRate == .normal)
+ #expect(selections == [.oneAndAHalf])
+ }
+
+ @Test func videoQualitySelectionRequiresAChangeableVideoOwner() {
+ let coordinator = PlexPlayerCoordinator()
+ var selections: [PlexVideoQuality] = []
+
+ coordinator.selectVideoQuality(.hd4Mbps)
+ #expect(selections.isEmpty)
+
+ coordinator.installVideoQuality(
+ selection: PlexVideoQualitySelection(
+ selectedQuality: .original,
+ isVideo: true,
+ canChange: false
+ )
+ ) {
+ selections.append($0)
+ }
+ coordinator.selectVideoQuality(.hd4Mbps)
+ #expect(selections.isEmpty)
+
+ coordinator.installVideoQuality(
+ selection: PlexVideoQualitySelection(
+ selectedQuality: .original,
+ isVideo: true,
+ canChange: true
+ )
+ ) {
+ selections.append($0)
+ }
+ coordinator.selectVideoQuality(.original)
+ coordinator.selectVideoQuality(.hd4Mbps)
+ #expect(selections == [.hd4Mbps])
+
+ coordinator.clearVideoQuality()
+ coordinator.selectVideoQuality(.fullHD8Mbps)
+ #expect(!coordinator.videoQualitySelection.isVideo)
+ #expect(selections == [.hd4Mbps])
+ }
+
+ @Test func serverManagedLanguageSelectionRequiresTheActiveExactChoice() throws {
+ let item = try JSONDecoder().decode(PlexMediaItem.self, from: Data(#"""
+ {
+ "ratingKey": "42",
+ "title": "Movie",
+ "Media": [{
+ "Part": [{
+ "id": "700",
+ "Stream": [
+ {"id":"21","streamType":"2","language":"English","selected":"1"},
+ {"id":"22","streamType":"2","language":"French"},
+ {"id":"31","streamType":"3","language":"English","selected":"1"},
+ {"id":"32","streamType":"3","language":"Spanish"}
+ ]
+ }]
+ }]
+ }
+ """#.utf8))
+ let selection = PlexServerManagedMediaSelection(
+ selection: PlexPlaybackMediaSelection(
+ item: item,
+ source: PlexPlaybackSource(mediaIndex: 0, partIndex: 0)
+ ),
+ nativeAvailability: PlexNativeMediaSelectionAvailability(
+ audioOptionCount: 0,
+ subtitleOptionCount: 0
+ )
+ )
+ let coordinator = PlexPlayerCoordinator()
+ var audioSelections: [Int] = []
+ var subtitleSelections: [Int?] = []
+
+ coordinator.installServerManagedMediaSelection(
+ selection: selection,
+ canChange: false,
+ selectAudioStream: { audioSelections.append($0) },
+ selectSubtitleStream: { subtitleSelections.append($0) }
+ )
+ coordinator.selectAudioStream(22)
+ coordinator.selectSubtitleStream(nil)
+ #expect(audioSelections.isEmpty)
+ #expect(subtitleSelections.isEmpty)
+
+ coordinator.updateServerManagedMediaSelection(
+ selection: selection,
+ canChange: true
+ )
+ coordinator.selectAudioStream(21)
+ coordinator.selectAudioStream(999)
+ coordinator.selectAudioStream(22)
+ coordinator.selectSubtitleStream(31)
+ coordinator.selectSubtitleStream(999)
+ coordinator.selectSubtitleStream(nil)
+ #expect(audioSelections == [22])
+ #expect(subtitleSelections == [nil])
+
+ coordinator.clearServerManagedMediaSelection()
+ coordinator.selectAudioStream(22)
+ coordinator.selectSubtitleStream(32)
+ #expect(!coordinator.canChangeServerManagedMediaSelection)
+ #expect(!coordinator.serverManagedMediaSelection.hasChoices)
+ #expect(audioSelections == [22])
+ #expect(subtitleSelections == [nil])
+ }
+
+ @Test func playbackReconfigurationPreservesPlayingAndPausedIntent() {
+ for status in [
+ PlexPlaybackStatus.preparing,
+ .playing,
+ .buffering,
+ ] {
+ let policy = PlexPlaybackReconfigurationPolicy(status: status)
+ #expect(policy.canReload)
+ #expect(policy.autoplay)
+ }
+
+ let pausedPolicy = PlexPlaybackReconfigurationPolicy(status: .paused)
+ #expect(pausedPolicy.canReload)
+ #expect(!pausedPolicy.autoplay)
+
+ for status in [
+ PlexPlaybackStatus.idle,
+ .ended,
+ .failed("Unavailable"),
+ ] {
+ let policy = PlexPlaybackReconfigurationPolicy(status: status)
+ #expect(!policy.canReload)
+ #expect(!policy.autoplay)
+ }
+ }
+
+ @Test func playbackRecoveryIsExplicitAndLimitedToAnActiveFailedSession() {
+ #expect(PlexPlaybackRecoveryPolicy.canRetry(
+ status: .failed("Connection lost"),
+ isLoading: false,
+ isActive: true,
+ didStop: false
+ ))
+ #expect(!PlexPlaybackRecoveryPolicy.canRetry(
+ status: .playing,
+ isLoading: false,
+ isActive: true,
+ didStop: false
+ ))
+ #expect(!PlexPlaybackRecoveryPolicy.canRetry(
+ status: .failed("Connection lost"),
+ isLoading: true,
+ isActive: true,
+ didStop: false
+ ))
+ #expect(!PlexPlaybackRecoveryPolicy.canRetry(
+ status: .failed("Connection lost"),
+ isLoading: false,
+ isActive: false,
+ didStop: false
+ ))
+ #expect(!PlexPlaybackRecoveryPolicy.canRetry(
+ status: .failed("Connection lost"),
+ isLoading: false,
+ isActive: true,
+ didStop: true
+ ))
+ }
+
+ @Test func failedNativePlayerRequiresReplacementButFailedItemDoesNotImplyIt() {
+ #expect(PlexNativePlayerRecoveryPolicy.requiresReplacement(status: .failed))
+ #expect(!PlexNativePlayerRecoveryPolicy.requiresReplacement(status: .unknown))
+ #expect(!PlexNativePlayerRecoveryPolicy.requiresReplacement(status: .readyToPlay))
+ }
+
+ @Test func playbackRecoveryPreservesTheExactSessionDecisionContext() throws {
+ let plan = PlexPlaybackPlan(
+ url: try #require(URL(string: "https://plex.example/recover.m3u8")),
+ method: .directStream,
+ mediaKind: .video,
+ sessionIdentifier: "old-session",
+ ratingKey: "42",
+ duration: 7_200,
+ startTime: 0,
+ source: PlexPlaybackSource(mediaIndex: 2, partIndex: -1),
+ usesServerMediaSelection: true
+ )
+
+ let request = PlexPlaybackRecoveryRequest(
+ plan: plan,
+ videoQuality: .fullHD8Mbps,
+ position: 1_234.567
+ )
+
+ #expect(request.source == PlexPlaybackSource(mediaIndex: 2, partIndex: -1))
+ #expect(request.videoQuality == .fullHD8Mbps)
+ #expect(request.startTime == 1_234.567)
+ #expect(request.forceServerMediaSelection)
+
+ let invalidPositionRequest = PlexPlaybackRecoveryRequest(
+ plan: plan,
+ videoQuality: .original,
+ position: .nan
+ )
+ #expect(invalidPositionRequest.startTime == 0)
+ }
+
+ @Test func shuffleChangesRequireAnAvailableOwnerAndRemainServerConfirmed() {
+ let coordinator = PlexPlayerCoordinator()
+ var selections: [Bool] = []
+
+ coordinator.setShuffled(true)
+ #expect(selections.isEmpty)
+
+ coordinator.installShuffle(isShuffled: false, canChange: true) {
+ selections.append($0)
+ }
+ coordinator.setShuffled(true)
+ #expect(selections == [true])
+ #expect(!coordinator.isShuffled)
+
+ coordinator.updateShuffle(isShuffled: true, canChange: true)
+ #expect(coordinator.isShuffled)
+ coordinator.setShuffled(true)
+ #expect(selections == [true])
+
+ coordinator.clearShuffle()
+ coordinator.setShuffled(false)
+ #expect(!coordinator.canChangeShuffle)
+ #expect(!coordinator.isShuffled)
+ #expect(selections == [true])
+ }
+
+ @Test func repeatModeSelectionRequiresAnOwnerAndRejectsUnavailableRepeatAll() {
+ let coordinator = PlexPlayerCoordinator()
+ var selections: [PlexPlaybackRepeatMode] = []
+
+ coordinator.selectRepeatMode(.one)
+ #expect(selections.isEmpty)
+
+ coordinator.installRepeatMode(repeatMode: .off, canRepeatAll: false) {
+ selections.append($0)
+ }
+ coordinator.selectRepeatMode(.all)
+ #expect(selections.isEmpty)
+
+ coordinator.selectRepeatMode(.one)
+ #expect(selections == [.one])
+ #expect(coordinator.repeatMode == .off)
+
+ coordinator.updateRepeatMode(repeatMode: .one, canRepeatAll: true)
+ coordinator.selectRepeatMode(.all)
+ #expect(selections == [.one, .all])
+
+ coordinator.clearRepeatMode()
+ coordinator.selectRepeatMode(.one)
+ #expect(!coordinator.canChangeRepeatMode)
+ #expect(!coordinator.canRepeatAll)
+ #expect(coordinator.repeatMode == .off)
+ #expect(selections == [.one, .all])
+ }
+
+ @Test func completionPolicyMatchesNativeRepeatSemantics() {
+ #expect(PlexPlaybackCompletionAction.resolve(
+ repeatMode: .off,
+ canAdvance: false,
+ canResetQueue: false
+ ) == .stop)
+ #expect(PlexPlaybackCompletionAction.resolve(
+ repeatMode: .off,
+ canAdvance: true,
+ canResetQueue: true
+ ) == .advanceNext)
+ #expect(PlexPlaybackCompletionAction.resolve(
+ repeatMode: .one,
+ canAdvance: true,
+ canResetQueue: true
+ ) == .replayCurrent)
+ #expect(PlexPlaybackCompletionAction.resolve(
+ repeatMode: .all,
+ canAdvance: true,
+ canResetQueue: true
+ ) == .advanceNext)
+ #expect(PlexPlaybackCompletionAction.resolve(
+ repeatMode: .all,
+ canAdvance: false,
+ canResetQueue: true
+ ) == .resetQueue)
+ #expect(PlexPlaybackCompletionAction.resolve(
+ repeatMode: .all,
+ canAdvance: false,
+ canResetQueue: false
+ ) == .stop)
+ }
+
+ @Test func playbackRateConfiguresAVPlayerWithoutStartingIdlePlayback() {
+ let engine = PlexPlaybackEngine()
+
+ engine.setPlaybackRate(.oneAndAHalf)
+ #expect(engine.playbackRate == .oneAndAHalf)
+ #expect(engine.player.defaultRate == PlexPlaybackRate.oneAndAHalf.rawValue)
+ #expect(engine.player.rate == 0)
+
+ engine.stop()
+ #expect(engine.playbackRate == .oneAndAHalf)
+ #expect(engine.player.defaultRate == PlexPlaybackRate.oneAndAHalf.rawValue)
+ #expect(engine.player.rate == 0)
+ }
+
+ @Test func nativePlayerSpeedControlUsesTheExactSessionRates() {
+ let player = AVPlayer()
+ let playerView = AVPlayerView()
+ playerView.player = player
+
+ PlexNativePlaybackSpeedConfiguration.apply(
+ to: playerView,
+ playbackRate: .oneAndAQuarter
+ )
+
+ #expect(playerView.speeds.map(\.rate) == PlexPlaybackRate.allCases.map(\.rawValue))
+ #expect(playerView.selectedSpeed?.rate == PlexPlaybackRate.oneAndAQuarter.rawValue)
+ #expect(player.defaultRate == PlexPlaybackRate.oneAndAQuarter.rawValue)
+ #expect(player.rate == 0)
+ }
+
+ @Test func nativePlayerSpeedsRoundTripOnlySupportedSessionRates() {
+ for playbackRate in PlexPlaybackRate.allCases {
+ let speed = PlexNativePlaybackSpeedConfiguration.speed(for: playbackRate)
+ #expect(speed?.localizedName == playbackRate.label)
+ #expect(
+ PlexNativePlaybackSpeedConfiguration.playbackRate(for: speed)
+ == playbackRate
+ )
+ }
+
+ let unsupportedSpeed = AVPlaybackSpeed(rate: 1.1, localizedName: "1.1×")
+ #expect(
+ PlexNativePlaybackSpeedConfiguration.playbackRate(for: unsupportedSpeed) == nil
+ )
+ #expect(PlexNativePlaybackSpeedConfiguration.playbackRate(for: nil) == nil)
+ }
+
+ @Test func nativePlaybackRateObservationRejectsStalePlayersAndValues() {
+ var epoch = PlexNativePlaybackRateObservationEpoch()
+ let firstPlayer = AVPlayer()
+ let secondPlayer = AVPlayer()
+
+ firstPlayer.defaultRate = PlexPlaybackRate.oneAndAHalf.rawValue
+ let firstTicket = epoch.begin(player: firstPlayer)
+ #expect(epoch.isCurrent(player: firstPlayer))
+ #expect(!epoch.isCurrent(player: secondPlayer))
+ #expect(
+ epoch.playbackRate(
+ for: PlexPlaybackRate.oneAndAHalf.rawValue,
+ ticket: firstTicket,
+ player: firstPlayer
+ ) == .oneAndAHalf
+ )
+
+ firstPlayer.defaultRate = PlexPlaybackRate.double.rawValue
+ #expect(
+ epoch.playbackRate(
+ for: PlexPlaybackRate.oneAndAHalf.rawValue,
+ ticket: firstTicket,
+ player: firstPlayer
+ ) == nil
+ )
+ #expect(
+ epoch.playbackRate(
+ for: PlexPlaybackRate.double.rawValue,
+ ticket: firstTicket,
+ player: firstPlayer
+ ) == .double
+ )
+
+ secondPlayer.defaultRate = PlexPlaybackRate.half.rawValue
+ let secondTicket = epoch.begin(player: secondPlayer)
+ #expect(!epoch.isCurrent(player: firstPlayer))
+ #expect(epoch.isCurrent(player: secondPlayer))
+ #expect(
+ epoch.playbackRate(
+ for: PlexPlaybackRate.double.rawValue,
+ ticket: firstTicket,
+ player: firstPlayer
+ ) == nil
+ )
+ #expect(
+ epoch.playbackRate(
+ for: PlexPlaybackRate.half.rawValue,
+ ticket: secondTicket,
+ player: secondPlayer
+ ) == .half
+ )
+
+ epoch.invalidate()
+ #expect(!epoch.isCurrent(player: secondPlayer))
+ #expect(
+ epoch.playbackRate(
+ for: PlexPlaybackRate.half.rawValue,
+ ticket: secondTicket,
+ player: secondPlayer
+ ) == nil
+ )
+ }
+
+ @Test func playbackRatesRoundTripOnlySupportedRemoteCommandValues() {
+ #expect(PlexPlaybackRate.allCases.map(\.rawValue) == [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2])
+ for playbackRate in PlexPlaybackRate.allCases {
+ #expect(
+ PlexPlaybackRate(remoteCommandValue: playbackRate.rawValue) == playbackRate
+ )
+ }
+ #expect(PlexPlaybackRate(remoteCommandValue: 1.1) == nil)
+ }
+
+ @Test func fullScreenLifecycleKeepsPlaybackAliveUntilAVKitFinishesExiting() {
+ let lifecycle = PlexPlayerPresentationLifecycle()
+
+ #expect(!lifecycle.keepsPlaybackAliveWhenViewDisappears)
+ lifecycle.willEnterFullScreen()
+ #expect(lifecycle.isFullScreenActive)
+ #expect(lifecycle.keepsPlaybackAliveWhenViewDisappears)
+
+ lifecycle.didExitFullScreen()
+ #expect(!lifecycle.isFullScreenActive)
+ #expect(!lifecycle.keepsPlaybackAliveWhenViewDisappears)
+ }
+
+ @Test func pictureInPictureLifecycleCoversFailureAndNormalStop() {
+ let lifecycle = PlexPlayerPresentationLifecycle()
+
+ lifecycle.willStartPictureInPicture()
+ #expect(lifecycle.isPictureInPictureActive)
+ #expect(lifecycle.keepsPlaybackAliveWhenViewDisappears)
+
+ lifecycle.failedToStartPictureInPicture()
+ #expect(!lifecycle.isPictureInPictureActive)
+ #expect(!lifecycle.keepsPlaybackAliveWhenViewDisappears)
+
+ lifecycle.willStartPictureInPicture()
+ lifecycle.didStopPictureInPicture()
+ #expect(!lifecycle.isPictureInPictureActive)
+ #expect(!lifecycle.keepsPlaybackAliveWhenViewDisappears)
+ }
+
+ @Test func presentationModesRemainIndependentWhenTransitionsOverlap() {
+ let lifecycle = PlexPlayerPresentationLifecycle()
+
+ lifecycle.willEnterFullScreen()
+ lifecycle.willStartPictureInPicture()
+ lifecycle.didExitFullScreen()
+
+ #expect(!lifecycle.isFullScreenActive)
+ #expect(lifecycle.isPictureInPictureActive)
+ #expect(lifecycle.keepsPlaybackAliveWhenViewDisappears)
+
+ lifecycle.didStopPictureInPicture()
+ #expect(!lifecycle.keepsPlaybackAliveWhenViewDisappears)
+ }
+}
+
+private func playbackPlan(
+ sessionIdentifier: String,
+ ratingKey: String
+) -> PlexPlaybackPlan {
+ PlexPlaybackPlan(
+ url: URL(fileURLWithPath: "/tmp/plexbar-\(sessionIdentifier).mp4"),
+ method: .directPlay,
+ mediaKind: .video,
+ sessionIdentifier: sessionIdentifier,
+ ratingKey: ratingKey,
+ duration: 120,
+ startTime: 0,
+ source: PlexPlaybackSource(mediaIndex: 0, partIndex: 0),
+ usesServerMediaSelection: false
+ )
+}
+
+@MainActor
+private final class PlexPlayerStageSizeRecorder {
+ var size = CGSize.zero
+}
+
+private struct PlexPlayerStageSizeProbe: NSViewRepresentable {
+ let recorder: PlexPlayerStageSizeRecorder
+
+ func makeNSView(context: Context) -> NSView {
+ PlexPlayerStageProbeNSView(recorder: recorder)
+ }
+
+ func updateNSView(_ nsView: NSView, context: Context) {}
+}
+
+@MainActor
+private final class PlexPlayerStageProbeNSView: NSView {
+ private let recorder: PlexPlayerStageSizeRecorder
+
+ init(recorder: PlexPlayerStageSizeRecorder) {
+ self.recorder = recorder
+ super.init(frame: .zero)
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ nil
+ }
+
+ override func setFrameSize(_ newSize: NSSize) {
+ super.setFrameSize(newSize)
+ recorder.size = newSize
+ }
+}
diff --git a/PlexBarTests/PlexPlaybackIntegrationFixtureTests.swift b/PlexBarTests/PlexPlaybackIntegrationFixtureTests.swift
new file mode 100644
index 0000000..3bf8ec4
--- /dev/null
+++ b/PlexBarTests/PlexPlaybackIntegrationFixtureTests.swift
@@ -0,0 +1,280 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+private final class PlexBarTestsBundleToken {}
+
+extension PlexMediaRequestTests {
+ @Test func directPlayFixtureSelectsTheExactServerPart() async throws {
+ let fixture = try playbackFixture("playback-decision-direct-play")
+ let item = try playbackFixtureItem(from: fixture)
+ let session = makeMediaMockSession { request in
+ (try Self.successResponse(for: request), fixture)
+ }
+
+ let plan = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try fixtureConfiguration,
+ capabilities: fixtureCapabilities
+ )
+
+ #expect(plan.method == .directPlay)
+ #expect(plan.mediaKind == .video)
+ #expect(plan.ratingKey == "42001")
+ #expect(plan.startTime == 12)
+ #expect(plan.duration == 634.533)
+ #expect(plan.url.path == "/library/parts/71001/fixture.mp4")
+ let queryItems = try #require(
+ URLComponents(url: plan.url, resolvingAgainstBaseURL: false)?.queryItems
+ )
+ #expect(queryItems.contains { $0.name == "X-Plex-Token" && $0.value == "server-token" })
+ #expect(queryItems.contains {
+ $0.name == "X-Plex-Session-Identifier" && $0.value == plan.sessionIdentifier
+ })
+ }
+
+ @Test func directStreamFixtureUsesTheAuthenticatedHLSStartContract() async throws {
+ let fixture = try playbackFixture("playback-decision-direct-stream")
+ let item = try playbackFixtureItem(from: fixture)
+ let session = makeMediaMockSession { request in
+ (try Self.successResponse(for: request), fixture)
+ }
+
+ let plan = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try fixtureConfiguration,
+ capabilities: fixtureCapabilities
+ )
+
+ #expect(plan.method == .directStream)
+ #expect(plan.url.path == "/video/:/transcode/universal/start.m3u8")
+ let queryItems = try #require(
+ URLComponents(url: plan.url, resolvingAgainstBaseURL: false)?.queryItems
+ )
+ #expect(queryItems.contains { $0.name == "protocol" && $0.value == "hls" })
+ #expect(queryItems.contains { $0.name == "X-Plex-Token" && $0.value == "server-token" })
+ #expect(queryItems.contains { $0.name == "X-Plex-Client-Profile-Name" && $0.value == "generic" })
+ }
+
+ @Test func publishedTranscodeFixtureSelectsTranscodeAndPreservesSessionContext() async throws {
+ let fixture = try playbackFixture("playback-decision-transcode")
+ let item = try playbackFixtureItem(from: fixture)
+ let session = makeMediaMockSession { request in
+ (try Self.successResponse(for: request), fixture)
+ }
+
+ let plan = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try fixtureConfiguration,
+ capabilities: fixtureCapabilities
+ )
+
+ #expect(plan.method == .transcode)
+ #expect(plan.ratingKey == "151671")
+ #expect(plan.url.path == "/video/:/transcode/universal/start.m3u8")
+ let queryItems = try #require(
+ URLComponents(url: plan.url, resolvingAgainstBaseURL: false)?.queryItems
+ )
+ #expect(queryItems.contains { $0.name == "session" && $0.value == plan.sessionIdentifier })
+ #expect(queryItems.contains {
+ $0.name == "X-Plex-Session-Identifier" && $0.value == plan.sessionIdentifier
+ })
+ }
+
+ @Test func musicTranscodeFixtureUsesPublishedMusicDecisionAndHLSStartContract() async throws {
+ let fixture = try playbackFixture("playback-decision-music-transcode")
+ let item = try playbackFixtureItem(from: fixture)
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ return (try Self.successResponse(for: request), fixture)
+ }
+
+ let plan = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try fixtureConfiguration,
+ capabilities: fixtureCapabilities
+ )
+
+ let decisionRequest = try #require(capture.request)
+ #expect(decisionRequest.url?.path == "/music/:/transcode/universal/decision")
+ #expect(plan.method == .transcode)
+ #expect(plan.mediaKind == .music)
+ #expect(plan.url.path == "/music/:/transcode/universal/start.m3u8")
+ let queryItems = try #require(
+ URLComponents(url: plan.url, resolvingAgainstBaseURL: false)?.queryItems
+ )
+ let profile = try #require(
+ queryItems.first(where: { $0.name == "X-Plex-Client-Profile-Extra" })?.value
+ )
+ #expect(profile.contains("type=musicProfile"))
+ #expect(profile.contains("protocol=hls"))
+ #expect(!profile.contains("type=videoProfile"))
+ #expect(queryItems.contains { $0.name == "musicBitrate" && $0.value == "921" })
+ }
+
+ @Test func rejectedDecisionFixtureSurfacesTheServerReason() async throws {
+ let sourceFixture = try playbackFixture("playback-decision-direct-play")
+ let rejectedFixture = try playbackFixture("playback-decision-rejected")
+ let item = try playbackFixtureItem(from: sourceFixture)
+ let session = makeMediaMockSession { request in
+ (try Self.successResponse(for: request), rejectedFixture)
+ }
+
+ do {
+ _ = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try fixtureConfiguration,
+ capabilities: fixtureCapabilities
+ )
+ Issue.record("Expected PMS to reject playback")
+ } catch let PlexAPIError.playbackRejected(reason) {
+ #expect(reason == "Playback is not possible for this item.")
+ } catch {
+ Issue.record("Unexpected playback error: \(error)")
+ }
+ }
+
+ @Test func timelineFixturesCoverStateChangesSeekProgressAndCompletion() async throws {
+ let fixture = try playbackFixture("timeline-normal")
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ return (try Self.successResponse(for: request), fixture)
+ }
+ let client = PlexAPIClient(session: session)
+ let updates: [(PlexTimelineState, Int, Bool?)] = [
+ (.buffering, 0, nil),
+ (.playing, 1_000, nil),
+ (.paused, 12_000, nil),
+ (.playing, 18_000, nil),
+ (.stopped, 60_000, false),
+ ]
+
+ for (state, time, continuing) in updates {
+ let response = try await client.reportTimeline(
+ PlexTimelineUpdate(
+ ratingKey: "42001",
+ state: state,
+ time: time,
+ duration: 60_000,
+ sessionIdentifier: "session-123",
+ playQueueItemID: "queue-item-9",
+ continuing: continuing
+ ),
+ endpointPath: "/provider/timeline",
+ using: try fixtureConfiguration
+ )
+
+ #expect(response.termination == nil)
+ let request = try #require(capture.request)
+ let queryItems = try #require(
+ URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems
+ )
+ #expect(queryItems.contains { $0.name == "state" && $0.value == state.rawValue })
+ #expect(queryItems.contains { $0.name == "time" && $0.value == String(time) })
+ #expect(queryItems.contains {
+ $0.name == "playQueueItemID" && $0.value == "queue-item-9"
+ })
+ #expect(queryItems.contains { $0.name == "continuing" } == (state == .stopped))
+ }
+ }
+
+ @Test func timelineTerminationFixtureDecodesTheAuthoritativeServerStop() async throws {
+ let fixture = try playbackFixture("timeline-terminated")
+ let session = makeMediaMockSession { request in
+ (try Self.successResponse(for: request), fixture)
+ }
+
+ let response = try await PlexAPIClient(session: session).reportTimeline(
+ PlexTimelineUpdate(
+ ratingKey: "42001",
+ state: .playing,
+ time: 12_000,
+ duration: 60_000,
+ sessionIdentifier: "session-123"
+ ),
+ endpointPath: "/provider/timeline",
+ using: try fixtureConfiguration
+ )
+
+ #expect(response.termination == PlexTimelineResponse.Termination(
+ code: 2006,
+ text: "Admin terminated playback with reason: Go Away"
+ ))
+ #expect(response.termination?.message == "Admin terminated playback with reason: Go Away")
+ }
+
+ @Test func continuingIsOmittedUntilTheTimelineStateIsStopped() async throws {
+ let fixture = try playbackFixture("timeline-normal")
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ return (try Self.successResponse(for: request), fixture)
+ }
+
+ _ = try await PlexAPIClient(session: session).reportTimeline(
+ PlexTimelineUpdate(
+ ratingKey: "42001",
+ state: .playing,
+ time: 12_000,
+ duration: 60_000,
+ sessionIdentifier: "session-123",
+ continuing: true
+ ),
+ endpointPath: "/provider/timeline",
+ using: try fixtureConfiguration
+ )
+
+ let request = try #require(capture.request)
+ let queryItems = try #require(
+ URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems
+ )
+ #expect(!queryItems.contains { $0.name == "continuing" })
+ }
+
+ private var fixtureConfiguration: PlexConnectionConfiguration {
+ get throws {
+ PlexConnectionConfiguration(
+ serverURL: try #require(URL(string: "https://plex.local:32400")),
+ token: "server-token",
+ clientContext: PlexClientContext(clientIdentifier: "client-123")
+ )
+ }
+ }
+
+ private var fixtureCapabilities: PlexPlaybackCapabilities {
+ PlexPlaybackCapabilities(
+ directPlayContainers: ["mp4"],
+ directPlayVideoCodecs: ["h264", "hevc"],
+ directPlayAudioCodecs: ["aac", "opus"],
+ directPlayMusicProfiles: [
+ PlexMusicDirectPlayProfile(container: "mp3", audioCodec: "mp3"),
+ PlexMusicDirectPlayProfile(container: "mp4", audioCodec: "aac"),
+ ]
+ )
+ }
+
+ private func playbackFixture(_ name: String) throws -> Data {
+ let url = try #require(Bundle(for: PlexBarTestsBundleToken.self).url(
+ forResource: name,
+ withExtension: "json"
+ ))
+ return try Data(contentsOf: url)
+ }
+
+ private func playbackFixtureItem(from data: Data) throws -> PlexMediaItem {
+ let decision = try JSONDecoder().decode(PlexPlaybackDecisionEnvelope.self, from: data)
+ return try #require(decision.mediaContainer.metadata.first)
+ }
+
+ private static func successResponse(for request: URLRequest) throws -> HTTPURLResponse {
+ try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ }
+}
diff --git a/PlexBarTests/PlexPlaybackMarkerTests.swift b/PlexBarTests/PlexPlaybackMarkerTests.swift
new file mode 100644
index 0000000..2ecfe0b
--- /dev/null
+++ b/PlexBarTests/PlexPlaybackMarkerTests.swift
@@ -0,0 +1,273 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+struct PlexPlaybackMarkerTests {
+ @Test func metadataDecodesPlexMarkerTimingAndFinalFlag() throws {
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "42",
+ "title": "Episode",
+ "type": "episode",
+ "Marker": [
+ {
+ "id": 101,
+ "type": "intro",
+ "startTimeOffset": "30000",
+ "endTimeOffset": 92000
+ },
+ {
+ "id": "102",
+ "type": "credits",
+ "startTimeOffset": 2500000,
+ "endTimeOffset": 2580000,
+ "final": "1"
+ }
+ ]
+ }
+ """#)
+
+ #expect(item.markers.count == 2)
+ #expect(item.markers[0].id == "101")
+ #expect(item.markers[0].startTimeOffset == 30_000)
+ #expect(item.markers[0].endTimeOffset == 92_000)
+ #expect(item.markers[1].id == "102")
+ #expect(item.markers[1].isFinal == true)
+ }
+
+ @Test func skipActionIsAvailableOnlyInsideRecognizedMarkerBounds() throws {
+ let markers = try decodeMarkers(#"""
+ [
+ { "id": 1, "type": "commercial", "startTimeOffset": 0, "endTimeOffset": 10000 },
+ { "id": 2, "type": "intro", "startTimeOffset": 30000, "endTimeOffset": 92000 },
+ { "id": 3, "type": "credit", "startTimeOffset": 2500000, "endTimeOffset": 2580000 }
+ ]
+ """#)
+
+ let commercial = try #require(
+ PlexPlaybackMarkerAction.active(in: markers, at: 0, duration: 2_600)
+ )
+ #expect(commercial.id == "1")
+ #expect(commercial.kind == .commercial)
+ #expect(commercial.label == "Skip Ads")
+ #expect(commercial.accessibilityHint == "Moves playback to the end of the commercial break.")
+ #expect(commercial.targetTime == 10)
+ #expect(PlexPlaybackMarkerAction.active(in: markers, at: 10, duration: 2_600) == nil)
+
+ #expect(PlexPlaybackMarkerAction.active(in: markers, at: 29.999, duration: 2_600) == nil)
+
+ let intro = try #require(
+ PlexPlaybackMarkerAction.active(in: markers, at: 30, duration: 2_600)
+ )
+ #expect(intro.id == "2")
+ #expect(intro.kind == .intro)
+ #expect(intro.label == "Skip Intro")
+ #expect(intro.targetTime == 92)
+ #expect(PlexPlaybackMarkerAction.active(in: markers, at: 92, duration: 2_600) == nil)
+
+ let credits = try #require(
+ PlexPlaybackMarkerAction.active(in: markers, at: 2_500, duration: 2_600)
+ )
+ #expect(credits.kind == .credits)
+ #expect(credits.label == "Skip Credits")
+ #expect(credits.targetTime == 2_580)
+ }
+
+ @Test func skipActionClampsToDurationAndRejectsInvalidMarkers() throws {
+ let markers = try decodeMarkers(#"""
+ [
+ { "type": "intro", "startTimeOffset": 10000 },
+ { "type": "credits", "startTimeOffset": 90000, "endTimeOffset": 110000 }
+ ]
+ """#)
+
+ let credits = try #require(
+ PlexPlaybackMarkerAction.active(in: markers, at: 95, duration: 100)
+ )
+ #expect(credits.id == "credits:90000:110000")
+ #expect(credits.targetTime == 100)
+ #expect(PlexPlaybackMarkerAction.active(in: markers, at: 100, duration: 100) == nil)
+ }
+
+ @Test func overlappingRecognizedMarkersChooseTheEarliestServerRange() throws {
+ let markers = try decodeMarkers(#"""
+ [
+ { "id": 9, "type": "credits", "startTimeOffset": 40000, "endTimeOffset": 90000 },
+ { "id": 8, "type": "intro", "startTimeOffset": 30000, "endTimeOffset": 80000 }
+ ]
+ """#)
+
+ let action = try #require(
+ PlexPlaybackMarkerAction.active(in: markers, at: 50, duration: 120)
+ )
+ #expect(action.id == "8")
+ #expect(action.kind == .intro)
+ }
+
+ @Test func publishedCreditSchemaAndCreditsExampleMapToTheSameAction() throws {
+ let markers = try decodeMarkers(#"""
+ [
+ { "id": 1, "type": "credit", "startTimeOffset": 10000, "endTimeOffset": 20000 },
+ { "id": 2, "type": "credits", "startTimeOffset": 30000, "endTimeOffset": 40000 }
+ ]
+ """#)
+
+ #expect(
+ PlexPlaybackMarkerAction.active(in: markers, at: 15, duration: 60)?.kind == .credits
+ )
+ #expect(
+ PlexPlaybackMarkerAction.active(in: markers, at: 35, duration: 60)?.kind == .credits
+ )
+ }
+
+ @Test func creditsStartUsesTheEarliestValidServerRange() throws {
+ let markers = try decodeMarkers(#"""
+ [
+ { "id": 1, "type": "intro", "startTimeOffset": 10000, "endTimeOffset": 20000 },
+ { "id": 2, "type": "credits", "startTimeOffset": 90000, "endTimeOffset": 110000 },
+ { "id": 3, "type": "credit", "startTimeOffset": 70000, "endTimeOffset": 80000 },
+ { "id": 4, "type": "credits", "startTimeOffset": 60000, "endTimeOffset": 60000 },
+ { "id": 5, "type": "credits", "startTimeOffset": 130000, "endTimeOffset": 140000 }
+ ]
+ """#)
+
+ #expect(PlexPlaybackMarkerAction.creditsStartTime(in: markers, duration: 120) == 70)
+ #expect(PlexPlaybackMarkerAction.creditsStartTime(in: markers, duration: 65) == nil)
+ }
+
+ @Test func manualPresentationFollowsTheExactPerMarkerPreference() throws {
+ let markers = try decodeMarkers(#"""
+ [
+ { "id": 1, "type": "intro", "startTimeOffset": 10000, "endTimeOffset": 20000 },
+ { "id": 2, "type": "commercial", "startTimeOffset": 30000, "endTimeOffset": 40000 },
+ { "id": 3, "type": "credits", "startTimeOffset": 50000, "endTimeOffset": 60000 }
+ ]
+ """#)
+ let preferences = PlexPlaybackMarkerPreferences(
+ intro: .manually,
+ ads: .disabled,
+ credits: .automatically
+ )
+
+ #expect(
+ PlexPlaybackMarkerAction.manual(
+ in: markers,
+ at: 15,
+ duration: 70,
+ preferences: preferences
+ )?.kind == .intro
+ )
+ #expect(
+ PlexPlaybackMarkerAction.manual(
+ in: markers,
+ at: 35,
+ duration: 70,
+ preferences: preferences
+ ) == nil
+ )
+ #expect(
+ PlexPlaybackMarkerAction.manual(
+ in: markers,
+ at: 55,
+ duration: 70,
+ preferences: preferences
+ ) == nil
+ )
+ }
+
+ @Test func automaticTransitionRunsOncePerMarkerEntryAndRearmsAfterLeaving() throws {
+ let markers = try decodeMarkers(#"""
+ [{ "id": 1, "type": "intro", "startTimeOffset": 10000, "endTimeOffset": 20000 }]
+ """#)
+ let action = try #require(
+ PlexPlaybackMarkerAction.active(in: markers, at: 15, duration: 30)
+ )
+ let preferences = PlexPlaybackMarkerPreferences(
+ intro: .automatically,
+ ads: .manually,
+ credits: .manually
+ )
+ var transition = PlexAutomaticPlaybackMarkerTransition()
+
+ #expect(transition.action(for: action, preferences: preferences) == action)
+ #expect(transition.action(for: action, preferences: preferences) == nil)
+ #expect(transition.action(for: nil, preferences: preferences) == nil)
+ #expect(transition.action(for: action, preferences: preferences) == action)
+
+ transition.retry(action)
+ #expect(transition.action(for: action, preferences: preferences) == action)
+
+ transition.reset()
+ #expect(transition.enteredAction == nil)
+ }
+
+ @Test func automaticTransitionNeverConsumesDisabledOrManualMarkers() throws {
+ let markers = try decodeMarkers(#"""
+ [{ "id": 1, "type": "credits", "startTimeOffset": 10000, "endTimeOffset": 20000 }]
+ """#)
+ let action = try #require(
+ PlexPlaybackMarkerAction.active(in: markers, at: 15, duration: 30)
+ )
+ var transition = PlexAutomaticPlaybackMarkerTransition()
+
+ for behavior in [PlexPlaybackMarkerBehavior.disabled, .manually] {
+ let preferences = PlexPlaybackMarkerPreferences(
+ intro: .manually,
+ ads: .manually,
+ credits: behavior
+ )
+ #expect(transition.action(for: action, preferences: preferences) == nil)
+ #expect(transition.enteredAction == nil)
+ }
+ }
+
+ @Test func validActionsAndAvailableKindsUseStablePlaybackOrder() throws {
+ let markers = try decodeMarkers(#"""
+ [
+ { "id": 4, "type": "credits", "startTimeOffset": 90000, "endTimeOffset": 110000 },
+ { "id": 2, "type": "commercial", "startTimeOffset": 30000, "endTimeOffset": 40000 },
+ { "id": 1, "type": "intro", "startTimeOffset": 10000, "endTimeOffset": 20000 },
+ { "id": 3, "type": "commercial", "startTimeOffset": 30000, "endTimeOffset": 50000 },
+ { "id": 5, "type": "preview", "startTimeOffset": 60000, "endTimeOffset": 70000 },
+ { "id": 6, "type": "credits", "startTimeOffset": 120000, "endTimeOffset": 130000 }
+ ]
+ """#)
+
+ let actions = PlexPlaybackMarkerAction.actions(in: markers, duration: 120)
+
+ #expect(actions.map(\.id) == ["1", "2", "3", "4"])
+ #expect(actions.last?.targetTime == 110)
+ #expect(PlexPlaybackMarkerAction.availableKinds(in: markers, duration: 120) == [
+ .intro,
+ .commercial,
+ .credits,
+ ])
+ }
+
+ @Test func commercialInterstitialsUseAllValidPlexAdsAndMergeOverlaps() throws {
+ let markers = try decodeMarkers(#"""
+ [
+ { "id": 1, "type": "commercial", "startTimeOffset": 30000, "endTimeOffset": 45000 },
+ { "id": 2, "type": "intro", "startTimeOffset": 10000, "endTimeOffset": 20000 },
+ { "id": 3, "type": "commercial", "startTimeOffset": 40000, "endTimeOffset": 60000 },
+ { "id": 4, "type": "commercial", "startTimeOffset": 60000, "endTimeOffset": 70000 },
+ { "id": 5, "type": "commercial", "startTimeOffset": 90000, "endTimeOffset": 130000 },
+ { "id": 6, "type": "commercial", "startTimeOffset": 140000, "endTimeOffset": 150000 }
+ ]
+ """#)
+
+ #expect(PlexPlaybackInterstitial.commercials(in: markers, duration: 120) == [
+ PlexPlaybackInterstitial(startTime: 30, duration: 40),
+ PlexPlaybackInterstitial(startTime: 90, duration: 30),
+ ])
+ }
+
+ private func decodeItem(_ json: String) throws -> PlexMediaItem {
+ try JSONDecoder().decode(PlexMediaItem.self, from: Data(json.utf8))
+ }
+
+ private func decodeMarkers(_ json: String) throws -> [PlexMediaMarker] {
+ try JSONDecoder().decode([PlexMediaMarker].self, from: Data(json.utf8))
+ }
+}
diff --git a/PlexBarTests/PlexPlaybackMetricsTests.swift b/PlexBarTests/PlexPlaybackMetricsTests.swift
new file mode 100644
index 0000000..655c1b2
--- /dev/null
+++ b/PlexBarTests/PlexPlaybackMetricsTests.swift
@@ -0,0 +1,172 @@
+import CoreGraphics
+import Foundation
+import Testing
+@testable import PlexBar
+
+struct PlexPlaybackMetricsTests {
+ @Test func metricsRemainAbsentUntilAVFoundationPublishesEvidence() {
+ let facts = PlexPlaybackMetricFacts()
+
+ #expect(!facts.receivedEvent)
+ #expect(facts.diagnosticFacts.isEmpty)
+ }
+
+ @Test func initialStartupStallsAndVariantSwitchesRemainExactFacts() throws {
+ let initialVariant = try #require(PlexPlaybackVariantFacts(
+ presentationSize: CGSize(width: 1_920, height: 1_080),
+ averageBitRate: 6_500_000,
+ peakBitRate: 8_000_000
+ ))
+ let switchedVariant = try #require(PlexPlaybackVariantFacts(
+ presentationSize: CGSize(width: 3_840, height: 2_160),
+ averageBitRate: 15_250_000,
+ peakBitRate: 20_000_000
+ ))
+ var facts = PlexPlaybackMetricFacts()
+
+ facts.recordInitialLikelyToKeepUp(timeTaken: 1.23456, variant: initialVariant)
+ facts.recordStall()
+ facts.recordStall()
+ facts.recordVariantSwitch(succeeded: true, to: switchedVariant)
+ facts.recordVariantSwitch(succeeded: false, to: initialVariant)
+
+ #expect(facts.initialStartupTime == 1.23456)
+ #expect(facts.stallCount == 2)
+ #expect(facts.successfulVariantSwitchCount == 1)
+ #expect(facts.failedVariantSwitchCount == 1)
+ #expect(facts.currentVariant == switchedVariant)
+ #expect(facts.diagnosticFacts.map { "\($0.label): \($0.value)" } == [
+ "Initial Startup: 1.235 sec",
+ "Playback Stalls: 2",
+ "Successful Variant Switches: 1",
+ "Failed Variant Switches: 1",
+ "Current Variant: 3840 × 2160 · 15.25 Mbps average · 20 Mbps peak",
+ ])
+ #expect(Set(facts.diagnosticFacts.map(\.id)).count == facts.diagnosticFacts.count)
+ }
+
+ @Test func failedSwitchNeverBecomesTheCurrentVariant() throws {
+ let currentVariant = try #require(PlexPlaybackVariantFacts(
+ presentationSize: CGSize(width: 1_920, height: 1_080),
+ averageBitRate: nil,
+ peakBitRate: nil
+ ))
+ let rejectedVariant = try #require(PlexPlaybackVariantFacts(
+ presentationSize: CGSize(width: 3_840, height: 2_160),
+ averageBitRate: nil,
+ peakBitRate: nil
+ ))
+ var facts = PlexPlaybackMetricFacts()
+
+ facts.recordInitialLikelyToKeepUp(timeTaken: 0, variant: currentVariant)
+ facts.recordVariantSwitch(succeeded: false, to: rejectedVariant)
+
+ #expect(facts.currentVariant == currentVariant)
+ #expect(facts.diagnosticFacts.map { "\($0.label): \($0.value)" } == [
+ "Initial Startup: 0 sec",
+ "Playback Stalls: 0",
+ "Failed Variant Switches: 1",
+ "Current Variant: 1920 × 1080",
+ ])
+ }
+
+ @Test func invalidMetricValuesAreOmittedRatherThanRoundedOrGuessed() {
+ #expect(PlexPlaybackVariantFacts(
+ presentationSize: CGSize(width: 1_920.5, height: 1_080),
+ averageBitRate: -.infinity,
+ peakBitRate: 0
+ ) == nil)
+
+ var facts = PlexPlaybackMetricFacts()
+ facts.recordInitialLikelyToKeepUp(timeTaken: .nan, variant: nil)
+
+ #expect(facts.initialStartupTime == nil)
+ #expect(facts.diagnosticFacts.map { "\($0.label): \($0.value)" } == [
+ "Playback Stalls: 0",
+ ])
+ }
+
+ @Test func networkBandwidthUsesExactResponseBytesAndTransferInterval() throws {
+ let responseStart = Date(timeIntervalSince1970: 100)
+ let responseEnd = Date(timeIntervalSince1970: 102)
+ let sample = try #require(PlexPlaybackBandwidthSample(
+ byteCount: 2_000_000,
+ responseStartTime: responseStart,
+ responseEndTime: responseEnd,
+ wasReadFromCache: false,
+ hadError: false
+ ))
+ var facts = PlexPlaybackMetricFacts()
+ facts.recordBandwidthSample(sample)
+
+ #expect(sample.bitsPerSecond == 8_000_000)
+ #expect(sample.measuredAt == responseEnd)
+ #expect(sample.byteCount == 2_000_000)
+ #expect(sample.transferDuration == 2)
+ #expect(facts.previousMeasuredBandwidth == nil)
+ #expect(facts.lastMeasuredBandwidth == sample)
+ #expect(facts.diagnosticFacts.last == PlexPlaybackMetricDiagnosticFact(
+ kind: .measuredBandwidth,
+ label: "Last Measured Bandwidth",
+ value: "8 Mbps"
+ ))
+ }
+
+ @Test func bandwidthFactsKeepThePreviousAndLatestExactMeasurements() throws {
+ let first = try #require(PlexPlaybackBandwidthSample(
+ byteCount: 1_000_000,
+ responseStartTime: Date(timeIntervalSince1970: 100),
+ responseEndTime: Date(timeIntervalSince1970: 102),
+ wasReadFromCache: false,
+ hadError: false
+ ))
+ let second = try #require(PlexPlaybackBandwidthSample(
+ byteCount: 3_000_000,
+ responseStartTime: Date(timeIntervalSince1970: 103),
+ responseEndTime: Date(timeIntervalSince1970: 105),
+ wasReadFromCache: false,
+ hadError: false
+ ))
+ var facts = PlexPlaybackMetricFacts()
+
+ facts.recordBandwidthSample(first)
+ facts.recordBandwidthSample(second)
+
+ #expect(facts.previousMeasuredBandwidth == first)
+ #expect(facts.lastMeasuredBandwidth == second)
+ }
+
+ @Test func cachedFailedEmptyAndInvalidTransfersNeverBecomeBandwidthFacts() {
+ let start = Date(timeIntervalSince1970: 100)
+ let end = Date(timeIntervalSince1970: 101)
+
+ #expect(PlexPlaybackBandwidthSample(
+ byteCount: 1,
+ responseStartTime: start,
+ responseEndTime: end,
+ wasReadFromCache: true,
+ hadError: false
+ ) == nil)
+ #expect(PlexPlaybackBandwidthSample(
+ byteCount: 1,
+ responseStartTime: start,
+ responseEndTime: end,
+ wasReadFromCache: false,
+ hadError: true
+ ) == nil)
+ #expect(PlexPlaybackBandwidthSample(
+ byteCount: 0,
+ responseStartTime: start,
+ responseEndTime: end,
+ wasReadFromCache: false,
+ hadError: false
+ ) == nil)
+ #expect(PlexPlaybackBandwidthSample(
+ byteCount: 1,
+ responseStartTime: end,
+ responseEndTime: start,
+ wasReadFromCache: false,
+ hadError: false
+ ) == nil)
+ }
+}
diff --git a/PlexBarTests/PlexPlaybackQualitySuggestionTests.swift b/PlexBarTests/PlexPlaybackQualitySuggestionTests.swift
new file mode 100644
index 0000000..00d1fb2
--- /dev/null
+++ b/PlexBarTests/PlexPlaybackQualitySuggestionTests.swift
@@ -0,0 +1,240 @@
+import Foundation
+import Testing
+@testable import PlexBar
+
+struct PlexPlaybackQualitySuggestionTests {
+ @Test func threeAuthoritativeStallsSuggestTheNextLowerQuality() throws {
+ var metrics = PlexPlaybackMetricFacts()
+ metrics.recordStall()
+ metrics.recordStall()
+ #expect(makeSuggestion(metrics: metrics, sourceBitrate: 7_000) == nil)
+
+ metrics.recordStall()
+ let qualitySuggestion = try #require(makeSuggestion(
+ metrics: metrics,
+ sourceBitrate: 7_000
+ ))
+
+ #expect(qualitySuggestion.targetQuality == .hd4Mbps)
+ #expect(qualitySuggestion.reason == .repeatedStalls(count: 3))
+ #expect(qualitySuggestion.message ==
+ "Playback stalled 3 times. Change to 720p · 4 Mbps for this item?")
+ }
+
+ @Test func savedMaximumQualityCapsTheSuggestedTarget() {
+ var metrics = PlexPlaybackMetricFacts()
+ metrics.recordStall()
+ metrics.recordStall()
+ metrics.recordStall()
+
+ #expect(makeSuggestion(
+ metrics: metrics,
+ sourceBitrate: 14_000,
+ maximumQuality: .hd2Mbps
+ )?.targetQuality == .hd2Mbps)
+ }
+
+ @Test func policyNeverGuessesWithoutAChangeableLowerQuality() {
+ var metrics = PlexPlaybackMetricFacts()
+ metrics.recordStall()
+ metrics.recordStall()
+ metrics.recordStall()
+
+ #expect(makeSuggestion(
+ isEnabled: false,
+ metrics: metrics,
+ sourceBitrate: 8_000
+ ) == nil)
+ #expect(makeSuggestion(
+ selection: PlexVideoQualitySelection(
+ selectedQuality: .original,
+ isVideo: true,
+ canChange: false
+ ),
+ metrics: metrics,
+ sourceBitrate: 8_000
+ ) == nil)
+ #expect(makeSuggestion(metrics: metrics, sourceBitrate: nil) == nil)
+ #expect(makeSuggestion(
+ selection: PlexVideoQualitySelection(
+ selectedQuality: .sd1500Kbps,
+ isVideo: true,
+ canChange: true
+ ),
+ metrics: metrics,
+ sourceBitrate: 8_000
+ ) == nil)
+ }
+
+ @Test func acceptedTargetsAreNotRepeated() {
+ var metrics = PlexPlaybackMetricFacts()
+ metrics.recordStall()
+ metrics.recordStall()
+ metrics.recordStall()
+
+ #expect(makeSuggestion(
+ metrics: metrics,
+ sourceBitrate: 7_000,
+ excludedQualities: [.hd4Mbps]
+ ) == nil)
+ }
+
+ @Test func improvedMeasuredBandwidthSuggestsTheHighestSafeUpgrade() throws {
+ var metrics = PlexPlaybackMetricFacts()
+ metrics.recordBandwidthSample(try bandwidthSample(megabitsPerSecond: 5))
+ metrics.recordBandwidthSample(try bandwidthSample(megabitsPerSecond: 8))
+
+ let suggestion = try #require(makeSuggestion(
+ selection: PlexVideoQualitySelection(
+ selectedQuality: .hd4Mbps,
+ isVideo: true,
+ canChange: true
+ ),
+ metrics: metrics,
+ sourceBitrate: 7_000
+ ))
+
+ #expect(suggestion.targetQuality == .original)
+ #expect(suggestion.reason == .improvedBandwidth)
+ #expect(suggestion.message ==
+ "Playback bandwidth increased. Change to Original for this item?")
+ }
+
+ @Test func improvedBandwidthRespectsTheSavedMaximumQuality() throws {
+ var metrics = PlexPlaybackMetricFacts()
+ metrics.recordBandwidthSample(try bandwidthSample(megabitsPerSecond: 8))
+ metrics.recordBandwidthSample(try bandwidthSample(megabitsPerSecond: 15))
+
+ #expect(makeSuggestion(
+ selection: PlexVideoQualitySelection(
+ selectedQuality: .hd4Mbps,
+ isVideo: true,
+ canChange: true
+ ),
+ metrics: metrics,
+ sourceBitrate: 18_000,
+ maximumQuality: .fullHD8Mbps
+ )?.targetQuality == .fullHD8Mbps)
+ }
+
+ @Test func upgradeRequiresAnIncreasingMeasurementAndALowerQualityTranscode() throws {
+ var oneSample = PlexPlaybackMetricFacts()
+ oneSample.recordBandwidthSample(try bandwidthSample(megabitsPerSecond: 15))
+
+ var decreasing = oneSample
+ decreasing.recordBandwidthSample(try bandwidthSample(megabitsPerSecond: 12))
+
+ let selection = PlexVideoQualitySelection(
+ selectedQuality: .hd4Mbps,
+ isVideo: true,
+ canChange: true
+ )
+ #expect(makeSuggestion(
+ selection: selection,
+ metrics: oneSample,
+ sourceBitrate: 10_000
+ ) == nil)
+ #expect(makeSuggestion(
+ selection: selection,
+ metrics: decreasing,
+ sourceBitrate: 10_000
+ ) == nil)
+ #expect(makeSuggestion(
+ selection: selection,
+ isTranscoding: false,
+ metrics: try increasingBandwidthMetrics(),
+ sourceBitrate: 10_000
+ ) == nil)
+ #expect(makeSuggestion(
+ selection: selection,
+ metrics: try increasingBandwidthMetrics(),
+ sourceBitrate: 4_000
+ ) == nil)
+ }
+
+ @Test func repeatedStallsTakePriorityOverAnAvailableUpgrade() throws {
+ var metrics = try increasingBandwidthMetrics()
+ metrics.recordStall()
+ metrics.recordStall()
+ metrics.recordStall()
+
+ let suggestion = try #require(makeSuggestion(
+ selection: PlexVideoQualitySelection(
+ selectedQuality: .fullHD8Mbps,
+ isVideo: true,
+ canChange: true
+ ),
+ metrics: metrics,
+ sourceBitrate: 18_000
+ ))
+
+ #expect(suggestion.targetQuality == .hd4Mbps)
+ #expect(suggestion.reason == .repeatedStalls(count: 3))
+ }
+
+ @Test func sessionStateKeepsUserIntentForTheCurrentItemOnly() {
+ var state = PlexPlaybackQualitySuggestionSessionState()
+ state.beginSession(itemKey: "movie-1")
+ state.recordAccepted(.hd4Mbps)
+
+ state.moveToItem(itemKey: "movie-1")
+ #expect(state.acceptedQualities == [.hd4Mbps])
+ #expect(!state.isSuppressed)
+
+ state.suppress()
+ #expect(state.isSuppressed)
+
+ state.moveToItem(itemKey: "movie-2")
+ #expect(state.itemKey == "movie-2")
+ #expect(state.acceptedQualities.isEmpty)
+ #expect(!state.isSuppressed)
+
+ state.reset()
+ #expect(state.itemKey == nil)
+ }
+
+ private func makeSuggestion(
+ isEnabled: Bool = true,
+ selection: PlexVideoQualitySelection = PlexVideoQualitySelection(
+ selectedQuality: .original,
+ isVideo: true,
+ canChange: true
+ ),
+ isTranscoding: Bool = true,
+ metrics: PlexPlaybackMetricFacts,
+ sourceBitrate: Int?,
+ maximumQuality: PlexVideoQuality = .original,
+ excludedQualities: Set = []
+ ) -> PlexPlaybackQualitySuggestion? {
+ PlexPlaybackQualitySuggestionPolicy.suggestion(
+ isEnabled: isEnabled,
+ selection: selection,
+ sourceBitrate: sourceBitrate,
+ maximumQuality: maximumQuality,
+ isTranscoding: isTranscoding,
+ metrics: metrics,
+ excludedQualities: excludedQualities
+ )
+ }
+
+ private func increasingBandwidthMetrics() throws -> PlexPlaybackMetricFacts {
+ var metrics = PlexPlaybackMetricFacts()
+ metrics.recordBandwidthSample(try bandwidthSample(megabitsPerSecond: 5))
+ metrics.recordBandwidthSample(try bandwidthSample(megabitsPerSecond: 12))
+ return metrics
+ }
+
+ private func bandwidthSample(
+ megabitsPerSecond: Double
+ ) throws -> PlexPlaybackBandwidthSample {
+ let duration: TimeInterval = 1
+ let byteCount = Int64(megabitsPerSecond * 1_000_000 * duration / 8)
+ return try #require(PlexPlaybackBandwidthSample(
+ byteCount: byteCount,
+ responseStartTime: Date(timeIntervalSince1970: 100),
+ responseEndTime: Date(timeIntervalSince1970: 100 + duration),
+ wasReadFromCache: false,
+ hadError: false
+ ))
+ }
+}
diff --git a/PlexBarTests/PlexPlaybackRequestTests.swift b/PlexBarTests/PlexPlaybackRequestTests.swift
new file mode 100644
index 0000000..99985fc
--- /dev/null
+++ b/PlexBarTests/PlexPlaybackRequestTests.swift
@@ -0,0 +1,1498 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+extension PlexMediaRequestTests {
+ @Test func audioOnlyPlaybackUsesMusicDecisionEndpointAndNativeProfile() async throws {
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, directPlayDecisionData())
+ }
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "42",
+ "title": "Chapter 3",
+ "type": "track",
+ "Media": [{
+ "container": "mp4",
+ "audioCodec": "aac",
+ "bitrate": "256",
+ "Part": [{"key": "/library/parts/7/chapter.m4b"}]
+ }]
+ }
+ """#)
+
+ let plan = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try playbackConfiguration,
+ capabilities: capabilities
+ )
+
+ let request = try #require(capture.request)
+ #expect(request.url?.path == "/music/:/transcode/universal/decision")
+ let profile = try #require(request.value(forHTTPHeaderField: "X-Plex-Client-Profile-Extra"))
+ #expect(profile.contains("type=musicProfile&container=mp4"))
+ #expect(profile.contains("audioCodec=aac"))
+ #expect(!profile.contains("type=videoProfile"))
+ let queryItems = try capturedQueryItems(capture)
+ #expect(queryItems.contains { $0.name == "musicBitrate" && $0.value == "256" })
+ #expect(!queryItems.contains { $0.name == "videoQuality" })
+ #expect(!queryItems.contains { $0.name == "autoAdjustQuality" })
+ #expect(!queryItems.contains { $0.name == "audioBoost" })
+ #expect(plan.method == .directPlay)
+ #expect(plan.mediaKind == .music)
+ }
+
+ @Test(arguments: PlexAudioBoost.allCases)
+ func videoPlaybackSendsTheExactAudioBoostPercentage(
+ audioBoost: PlexAudioBoost
+ ) async throws {
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, directPlayDecisionData())
+ }
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "42",
+ "title": "Charade",
+ "type": "movie",
+ "Media": [{
+ "container": "mp4",
+ "videoCodec": "h264",
+ "audioCodec": "ac3",
+ "Part": [{"key": "/library/parts/7/file.mp4"}]
+ }]
+ }
+ """#)
+
+ _ = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try playbackConfiguration,
+ capabilities: capabilities,
+ audioBoost: audioBoost
+ )
+
+ let queryItems = try capturedQueryItems(capture)
+ #expect(queryItems.contains {
+ $0.name == "audioBoost" && $0.value == String(audioBoost.rawValue)
+ })
+ }
+
+ @Test(arguments: [
+ (6, true),
+ (2, false),
+ ])
+ func playbackPlanExposesAudioBoostOnlyForAProvenSurroundDownmix(
+ sourceChannels: Int,
+ expectedSupport: Bool
+ ) async throws {
+ let session = makeMediaMockSession { request in
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, Data(#"""
+ {
+ "MediaContainer": {
+ "generalDecisionCode": "1000",
+ "Metadata": [{
+ "ratingKey": "42",
+ "title": "Charade",
+ "Media": [{
+ "selected": "1",
+ "Part": [{
+ "selected": "1",
+ "decision": "transcode",
+ "Stream": [
+ { "streamType": "1", "decision": "copy" },
+ { "streamType": "2", "decision": "transcode", "channels": "2" }
+ ]
+ }]
+ }]
+ }]
+ }
+ }
+ """#.utf8))
+ }
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "42",
+ "title": "Charade",
+ "type": "movie",
+ "Media": [{
+ "container": "mp4",
+ "videoCodec": "h264",
+ "audioCodec": "ac3",
+ "Part": [{
+ "key": "/library/parts/7/file.mp4",
+ "Stream": [{
+ "streamType": "2",
+ "codec": "ac3",
+ "selected": "1",
+ "channels": "\#(sourceChannels)"
+ }]
+ }]
+ }]
+ }
+ """#)
+
+ let plan = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try playbackConfiguration,
+ capabilities: capabilities
+ )
+
+ #expect(plan.supportsAudioBoost == expectedSupport)
+ }
+
+ @Test(arguments: [
+ (PlexMusicQuality.original, "1", "1", "1", "256"),
+ (PlexMusicQuality.kbps320, "1", "1", "1", "320"),
+ (PlexMusicQuality.kbps128, "0", "0", "0", "128"),
+ ])
+ func remoteMusicQualityIsAnExactServerEnforcedCeiling(
+ quality: PlexMusicQuality,
+ expectedDirectPlay: String,
+ expectedDirectStream: String,
+ expectedDirectStreamAudio: String,
+ expectedMusicBitrate: String
+ ) async throws {
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, directPlayDecisionData())
+ }
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "42",
+ "title": "Remote Track",
+ "type": "track",
+ "Media": [{
+ "container": "mp4",
+ "audioCodec": "aac",
+ "bitrate": "256",
+ "Part": [{"container": "mp4", "key": "/library/parts/7/track.m4a"}]
+ }]
+ }
+ """#)
+
+ _ = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try playbackConfiguration,
+ capabilities: capabilities,
+ musicQuality: quality
+ )
+
+ let queryItems = try capturedQueryItems(capture)
+ #expect(queryItems.contains {
+ $0.name == "directPlay" && $0.value == expectedDirectPlay
+ })
+ #expect(queryItems.contains {
+ $0.name == "directStream" && $0.value == expectedDirectStream
+ })
+ #expect(queryItems.contains {
+ $0.name == "directStreamAudio" && $0.value == expectedDirectStreamAudio
+ })
+ #expect(queryItems.contains {
+ $0.name == "musicBitrate" && $0.value == expectedMusicBitrate
+ })
+ }
+
+ @Test func musicQualityCeilingOverridesForceDirectPlay() async throws {
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, transcodeDecisionData())
+ }
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "42",
+ "title": "Lossless Remote Track",
+ "type": "track",
+ "Media": [{
+ "container": "mp4",
+ "audioCodec": "aac",
+ "bitrate": "921",
+ "Part": [{
+ "container": "mp4",
+ "key": "/library/parts/7/track.m4a",
+ "Stream": [{"streamType": "2", "codec": "aac", "selected": "1"}]
+ }]
+ }]
+ }
+ """#)
+
+ _ = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try playbackConfiguration,
+ capabilities: capabilities,
+ musicQuality: .kbps192,
+ streamingPolicy: PlexPlaybackStreamingPolicy(
+ allowsDirectPlay: true,
+ allowsDirectStream: true,
+ forceDirectPlay: true
+ )
+ )
+
+ let queryItems = try capturedQueryItems(capture)
+ #expect(queryItems.contains { $0.name == "directPlay" && $0.value == "0" })
+ #expect(queryItems.contains { $0.name == "directStreamAudio" && $0.value == "0" })
+ #expect(queryItems.contains { $0.name == "musicBitrate" && $0.value == "192" })
+ }
+
+ @Test func musicQualityCeilingDoesNotGuessWhenSourceBitrateIsMissing() async throws {
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, transcodeDecisionData())
+ }
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "42",
+ "title": "Unknown Bitrate Track",
+ "type": "track",
+ "Media": [{
+ "container": "mp4",
+ "audioCodec": "aac",
+ "Part": [{"container": "mp4", "key": "/library/parts/7/track.m4a"}]
+ }]
+ }
+ """#)
+
+ _ = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try playbackConfiguration,
+ capabilities: capabilities,
+ musicQuality: .kbps192
+ )
+
+ let queryItems = try capturedQueryItems(capture)
+ #expect(queryItems.contains { $0.name == "directPlay" && $0.value == "0" })
+ #expect(queryItems.contains { $0.name == "directStreamAudio" && $0.value == "0" })
+ #expect(queryItems.contains { $0.name == "musicBitrate" && $0.value == "192" })
+ }
+
+ @Test func trackWithoutSelectedAudioFactsDoesNotGuessAMusicContract() async throws {
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, directPlayDecisionData())
+ }
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "42",
+ "title": "Unknown Track",
+ "type": "track",
+ "Media": [{"Part": [{"key": "/library/parts/7/unknown"}]}]
+ }
+ """#)
+
+ let plan = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try playbackConfiguration,
+ capabilities: capabilities
+ )
+
+ let request = try #require(capture.request)
+ #expect(request.url?.path == "/video/:/transcode/universal/decision")
+ let profile = try #require(request.value(forHTTPHeaderField: "X-Plex-Client-Profile-Extra"))
+ #expect(profile.contains("type=videoProfile"))
+ #expect(!profile.contains("type=musicProfile"))
+ #expect(plan.mediaKind == .video)
+ }
+
+ @Test func playbackDecisionUsesTheExplicitMediaVersion() async throws {
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, directPlayDecisionData())
+ }
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "42",
+ "title": "Charade",
+ "Media": [
+ { "Part": [{ "key": "/library/parts/4/file-720.mp4" }] },
+ {
+ "videoCodec": "hevc",
+ "width": "3840",
+ "height": "2160",
+ "bitrate": "48720",
+ "Part": [{ "key": "/library/parts/7/file-4k.mp4" }]
+ }
+ ]
+ }
+ """#)
+
+ _ = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try playbackConfiguration,
+ capabilities: capabilities,
+ source: PlexPlaybackSource(mediaIndex: 1, partIndex: 0)
+ )
+
+ let queryItems = try capturedQueryItems(capture)
+ #expect(queryItems.contains { $0.name == "mediaIndex" && $0.value == "1" })
+ #expect(queryItems.contains { $0.name == "partIndex" && $0.value == "0" })
+ #expect(queryItems.contains { $0.name == "videoQuality" && $0.value == "99" })
+ #expect(queryItems.contains { $0.name == "videoResolution" && $0.value == "3840x2160" })
+ #expect(!queryItems.contains { $0.name == "videoBitrate" })
+ }
+
+ @Test func originalHEVCRemuxKeepsTheSameUncappedProfileThroughPlaybackStart() async throws {
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil
+ ))
+ // PMS still calls the container conversion a transcode; the stream
+ // decisions establish that video and audio are both copied intact.
+ return (response, Data(#"""
+ {"MediaContainer": {
+ "generalDecisionCode": 1001,
+ "Metadata": [{"ratingKey": "42", "title": "HEVC movie", "Media": [{
+ "selected": true, "container": "mp4", "videoCodec": "hevc",
+ "width": 1920, "height": 1080, "audioCodec": "aac", "audioChannels": 6,
+ "Part": [{"selected": true, "decision": "transcode", "Stream": [
+ {"streamType": 1, "codec": "hevc", "decision": "copy"},
+ {"streamType": 2, "codec": "aac", "channels": 6, "decision": "copy"}
+ ]}]
+ }]}]
+ }}
+ """#.utf8))
+ }
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "42", "title": "HEVC movie", "Media": [{
+ "container": "mkv", "videoCodec": "hevc", "width": 1920, "height": 1080,
+ "bitrate": 2218, "audioCodec": "aac", "audioChannels": 6,
+ "Part": [{"key": "/library/parts/7/file.mkv"}]
+ }]
+ }
+ """#)
+
+ let plan = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try playbackConfiguration,
+ capabilities: capabilities,
+ videoQuality: .original
+ )
+
+ #expect(plan.method == .directStream)
+ #expect(plan.url.path == "/video/:/transcode/universal/start.m3u8")
+ let startItems = try #require(URLComponents(
+ url: plan.url, resolvingAgainstBaseURL: false
+ )?.queryItems)
+ for items in [try capturedQueryItems(capture), startItems] {
+ #expect(!items.contains { $0.name == "videoBitrate" || $0.name == "maxVideoBitrate" })
+ #expect(items.contains { $0.name == "videoResolution" && $0.value == "1920x1080" })
+ #expect(items.contains { $0.name == "directStream" && $0.value == "1" })
+ #expect(items.contains { $0.name == "directStreamAudio" && $0.value == "1" })
+ }
+ let decisionProfile = try #require(capture.request?.value(
+ forHTTPHeaderField: "X-Plex-Client-Profile-Extra"
+ ))
+ #expect(decisionProfile.contains(
+ "container=mp4&videoCodec=h264,hevc&audioCodec=aac&replace=true"
+ ))
+ #expect(startItems.first { $0.name == "X-Plex-Client-Profile-Extra" }?.value == decisionProfile)
+ }
+
+ @Test func multipartVersionAsksPlexToJoinItsParts() throws {
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "42",
+ "title": "Charade",
+ "Media": [{
+ "selected": "1",
+ "Part": [
+ { "key": "/library/parts/7/disc-1.mp4" },
+ { "key": "/library/parts/8/disc-2.mp4" }
+ ]
+ }]
+ }
+ """#)
+
+ #expect(item.defaultPlaybackSource == PlexPlaybackSource(mediaIndex: 0, partIndex: -1))
+ }
+
+ @Test func playbackDecisionRejectsAnInvalidSourceBeforeRequestingPlex() async throws {
+ let session = makeMediaMockSession { _ in
+ Issue.record("Invalid source should not make a request")
+ throw URLError(.badServerResponse)
+ }
+ let item = try JSONDecoder().decode(PlexMediaItem.self, from: playableItemData())
+
+ await #expect(throws: PlexAPIError.self) {
+ try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try playbackConfiguration,
+ capabilities: capabilities,
+ source: PlexPlaybackSource(mediaIndex: 8, partIndex: 0)
+ )
+ }
+ }
+
+ @Test func limitedVideoQualityForcesTheRequestedTranscodeLimits() async throws {
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, transcodeDecisionData())
+ }
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "42",
+ "title": "Charade",
+ "Media": [{
+ "videoCodec": "hevc",
+ "width": "3840",
+ "height": "2160",
+ "bitrate": "48720",
+ "Part": [{ "key": "/library/parts/7/file-4k.mp4" }]
+ }]
+ }
+ """#)
+
+ _ = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try playbackConfiguration,
+ capabilities: capabilities,
+ videoQuality: .fullHD8Mbps
+ )
+
+ let queryItems = try capturedQueryItems(capture)
+ #expect(queryItems.contains { $0.name == "directPlay" && $0.value == "0" })
+ #expect(queryItems.contains { $0.name == "directStream" && $0.value == "0" })
+ #expect(queryItems.contains { $0.name == "videoResolution" && $0.value == "1920x1080" })
+ #expect(queryItems.contains { $0.name == "videoBitrate" && $0.value == "8000" })
+ }
+
+ @Test func qualityCeilingKeepsASourceWithinItsLimitsEligibleForDirectPlay() async throws {
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, directPlayDecisionData())
+ }
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "42",
+ "title": "Already Small Enough",
+ "Media": [{
+ "videoCodec": "h264",
+ "width": "1280",
+ "height": "720",
+ "bitrate": "3500",
+ "Part": [{"key": "/library/parts/7/file.mp4"}]
+ }]
+ }
+ """#)
+
+ _ = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try playbackConfiguration,
+ capabilities: capabilities,
+ videoQuality: .fullHD8Mbps
+ )
+
+ let queryItems = try capturedQueryItems(capture)
+ #expect(queryItems.contains { $0.name == "directPlay" && $0.value == "1" })
+ #expect(queryItems.contains { $0.name == "directStream" && $0.value == "1" })
+ }
+
+ @Test(arguments: [
+ (PlexVideoQuality.fullHD8Mbps, false, "0", "0", "0"),
+ (PlexVideoQuality.fullHD8Mbps, true, "1", "1", "1"),
+ (PlexVideoQuality.original, false, "1", "1", "1"),
+ ])
+ func smallerVideoOriginalQualityPolicyControlsAdaptiveConversion(
+ quality: PlexVideoQuality,
+ playsSmallerAtOriginal: Bool,
+ expectedDirectPlay: String,
+ expectedDirectStream: String,
+ expectedDirectStreamAudio: String
+ ) async throws {
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, transcodeDecisionData())
+ }
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "42",
+ "title": "Already Small Enough",
+ "type": "movie",
+ "Media": [{
+ "videoCodec": "h264",
+ "width": "1280",
+ "height": "720",
+ "bitrate": "3500",
+ "Part": [{"key": "/library/parts/7/file.mp4"}]
+ }]
+ }
+ """#)
+
+ _ = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try playbackConfiguration,
+ capabilities: capabilities,
+ videoQuality: quality,
+ automaticallyAdjustVideoQuality: true,
+ playSmallerVideosAtOriginalQuality: playsSmallerAtOriginal
+ )
+
+ let queryItems = try capturedQueryItems(capture)
+ #expect(queryItems.contains {
+ $0.name == "directPlay" && $0.value == expectedDirectPlay
+ })
+ #expect(queryItems.contains {
+ $0.name == "directStream" && $0.value == expectedDirectStream
+ })
+ #expect(queryItems.contains {
+ $0.name == "directStreamAudio" && $0.value == expectedDirectStreamAudio
+ })
+ }
+
+ @Test func qualityCeilingRequiresServerEnforcementWhenSourceFactsAreIncomplete() async throws {
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, transcodeDecisionData())
+ }
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "42",
+ "title": "Unknown Source Size",
+ "Media": [{
+ "videoCodec": "h264",
+ "Part": [{"key": "/library/parts/7/file.mp4"}]
+ }]
+ }
+ """#)
+
+ _ = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try playbackConfiguration,
+ capabilities: capabilities,
+ videoQuality: .hd4Mbps
+ )
+
+ let queryItems = try capturedQueryItems(capture)
+ #expect(queryItems.contains { $0.name == "directPlay" && $0.value == "0" })
+ #expect(queryItems.contains { $0.name == "directStream" && $0.value == "0" })
+ }
+
+ @Test func playbackDecisionPreservesSubsecondReloadPosition() async throws {
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, directPlayDecisionData())
+ }
+ let item = try JSONDecoder().decode(PlexMediaItem.self, from: playableItemData())
+
+ _ = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try playbackConfiguration,
+ capabilities: capabilities,
+ startTimeOverride: 123.4567
+ )
+
+ #expect(try capturedQueryItems(capture).contains {
+ $0.name == "offset" && $0.value == "123.457"
+ })
+ }
+
+ @Test func explicitStartTimeOverridesAStoredResumeOffset() async throws {
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, directPlayDecisionData())
+ }
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "42",
+ "title": "Previously Watched Episode",
+ "type": "episode",
+ "viewOffset": "3599000",
+ "Media": [{"Part": [{"key": "/library/parts/7/file.mp4"}]}]
+ }
+ """#)
+
+ let plan = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try playbackConfiguration,
+ capabilities: capabilities,
+ startTimeOverride: 0
+ )
+
+ #expect(plan.startTime == 0)
+ #expect(try capturedQueryItems(capture).contains { $0.name == "offset" && $0.value == "0" })
+ }
+
+ @Test func playbackStartOptionsKeepResumeServerOwnedAndBeginningExplicit() {
+ #expect(PlexPlaybackStartOption.resume.startTimeOverride == nil)
+ #expect(PlexPlaybackStartOption.beginning.startTimeOverride == 0)
+ }
+
+ @Test func cinemaPreplayAppliesOnlyToFreshMovieStarts() throws {
+ let movie = try decodeItem(#"{"ratingKey":"42","key":"/library/metadata/42","title":"Feature","type":"movie","Media":[]}"#)
+ let episode = try decodeItem(#"{"ratingKey":"43","key":"/library/metadata/43","title":"Episode","type":"episode","Media":[]}"#)
+
+ #expect(PlexCinemaPreplayRequestPolicy.extrasPrefixCount(
+ for: movie,
+ startOption: .beginning,
+ preference: .off
+ ) == nil)
+ #expect(PlexCinemaPreplayRequestPolicy.extrasPrefixCount(
+ for: movie,
+ startOption: .beginning,
+ preference: .preRollOnly
+ ) == 0)
+ #expect(PlexCinemaPreplayRequestPolicy.extrasPrefixCount(
+ for: movie,
+ startOption: .beginning,
+ preference: .fiveTrailers
+ ) == 5)
+ #expect(PlexCinemaPreplayRequestPolicy.extrasPrefixCount(
+ for: movie,
+ startOption: .resume,
+ preference: .fiveTrailers
+ ) == nil)
+ #expect(PlexCinemaPreplayRequestPolicy.extrasPrefixCount(
+ for: episode,
+ startOption: .beginning,
+ preference: .fiveTrailers
+ ) == nil)
+ }
+
+ @Test func cinemaQueueSourcePreferenceTargetsOnlyTheRequestedMovie() throws {
+ let movie = try decodeItem(#"{"ratingKey":"42","title":"Feature","type":"movie","Media":[]}"#)
+ let trailer = try decodeItem(#"{"ratingKey":"700","title":"Trailer","type":"clip","Media":[]}"#)
+ let source = PlexPlaybackSource(mediaIndex: 2, partIndex: 0)
+ let preference = PlexPlaybackQueueSourcePreference(
+ ratingKey: movie.ratingKey,
+ source: source
+ )
+
+ #expect(preference.source(for: movie) == source)
+ #expect(preference.source(for: trailer) == nil)
+ }
+
+ @Test func resumeRequiresAPositiveServerOffset() throws {
+ let withoutOffset = try decodeItem(#"""
+ {
+ "ratingKey": "40",
+ "title": "Unstarted",
+ "Media": []
+ }
+ """#)
+ let zeroOffset = try decodeItem(#"""
+ {
+ "ratingKey": "41",
+ "title": "At Beginning",
+ "viewOffset": "0",
+ "Media": []
+ }
+ """#)
+ let positiveOffset = try decodeItem(#"""
+ {
+ "ratingKey": "42",
+ "title": "In Progress",
+ "viewOffset": "12500",
+ "Media": []
+ }
+ """#)
+
+ #expect(!withoutOffset.hasResumePosition)
+ #expect(!zeroOffset.hasResumePosition)
+ #expect(positiveOffset.hasResumePosition)
+ }
+
+ @Test func explicitServerMediaSelectionDisablesDirectPlayButKeepsDirectStreamAvailable() async throws {
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, transcodeDecisionData())
+ }
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "42",
+ "title": "Alternate Audio",
+ "Media": [{
+ "videoCodec": "h264",
+ "width": "1920",
+ "height": "1080",
+ "bitrate": "8000",
+ "Part": [{"key": "/library/parts/7/file.mp4"}]
+ }]
+ }
+ """#)
+
+ let plan = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try playbackConfiguration,
+ capabilities: capabilities,
+ forceServerMediaSelection: true
+ )
+
+ let queryItems = try capturedQueryItems(capture)
+ #expect(queryItems.contains { $0.name == "directPlay" && $0.value == "0" })
+ #expect(queryItems.contains { $0.name == "directStream" && $0.value == "1" })
+ #expect(plan.usesServerMediaSelection)
+ }
+
+ @Test(arguments: [
+ (PlexPlaybackStreamingPolicy(allowsDirectPlay: false, allowsDirectStream: true), "0", "1", "1"),
+ (PlexPlaybackStreamingPolicy(allowsDirectPlay: true, allowsDirectStream: false), "1", "0", "0"),
+ (PlexPlaybackStreamingPolicy(allowsDirectPlay: false, allowsDirectStream: false), "0", "0", "0"),
+ ])
+ func streamingPolicyMapsExactlyToPlexDecisionFlags(
+ policy: PlexPlaybackStreamingPolicy,
+ expectedDirectPlay: String,
+ expectedDirectStream: String,
+ expectedDirectStreamAudio: String
+ ) async throws {
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, transcodeDecisionData())
+ }
+ let item = try JSONDecoder().decode(PlexMediaItem.self, from: playableItemData())
+
+ _ = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try playbackConfiguration,
+ capabilities: capabilities,
+ streamingPolicy: policy
+ )
+
+ let queryItems = try capturedQueryItems(capture)
+ #expect(queryItems.contains { $0.name == "directPlay" && $0.value == expectedDirectPlay })
+ #expect(queryItems.contains { $0.name == "directStream" && $0.value == expectedDirectStream })
+ #expect(queryItems.contains {
+ $0.name == "directStreamAudio" && $0.value == expectedDirectStreamAudio
+ })
+ }
+
+ @Test(arguments: [
+ (PlexSubtitleBurnMode.automatic, "auto", "burn"),
+ (PlexSubtitleBurnMode.always, "burn", "burn"),
+ (PlexSubtitleBurnMode.imageFormatsOnly, "auto", "text"),
+ ])
+ func subtitleBurnModeMapsExactlyToPlexDecisionParameters(
+ mode: PlexSubtitleBurnMode,
+ expectedSubtitles: String,
+ expectedAdvancedSubtitles: String
+ ) async throws {
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, transcodeDecisionData())
+ }
+ let item = try JSONDecoder().decode(PlexMediaItem.self, from: playableItemData())
+
+ _ = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try playbackConfiguration,
+ capabilities: capabilities,
+ subtitleBurnMode: mode
+ )
+
+ let queryItems = try capturedQueryItems(capture)
+ #expect(queryItems.contains {
+ $0.name == "subtitles" && $0.value == expectedSubtitles
+ })
+ #expect(queryItems.contains {
+ $0.name == "advancedSubtitles" && $0.value == expectedAdvancedSubtitles
+ })
+ }
+
+ @Test(arguments: PlexSubtitleSize.allCases)
+ func subtitleSizeMapsToPlexDecisionPercentage(
+ size: PlexSubtitleSize
+ ) async throws {
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, transcodeDecisionData())
+ }
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "42",
+ "title": "Sized Subtitles",
+ "type": "movie",
+ "Media": [{
+ "container": "mp4",
+ "videoCodec": "h264",
+ "audioCodec": "aac",
+ "Part": [{"id": "700", "key": "/library/parts/700/file.mp4"}]
+ }]
+ }
+ """#)
+
+ _ = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try playbackConfiguration,
+ capabilities: capabilities,
+ subtitleSize: size
+ )
+
+ #expect(try capturedQueryItems(capture).contains {
+ $0.name == "subtitleSize" && $0.value == String(size.rawValue)
+ })
+ }
+
+ @Test(arguments: [
+ (true, true, "1"),
+ (true, false, "0"),
+ (false, true, "0"),
+ ])
+ func subtitleAutoSyncRequiresBothUserPreferenceAndStreamCapability(
+ isEnabled: Bool,
+ canAutoSync: Bool,
+ expectedValue: String
+ ) async throws {
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, transcodeDecisionData())
+ }
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "42",
+ "title": "Subtitle Sync",
+ "type": "movie",
+ "Media": [{
+ "container": "mp4",
+ "videoCodec": "h264",
+ "audioCodec": "aac",
+ "Part": [{
+ "id": "700",
+ "key": "/library/parts/700/file.mp4",
+ "Stream": [{
+ "id": "31",
+ "streamType": "3",
+ "codec": "srt",
+ "selected": "1",
+ "canAutoSync": ":canAutoSync"
+ }]
+ }]
+ }]
+ }
+ """#.replacing(":canAutoSync", with: canAutoSync ? "1" : "0"))
+
+ let plan = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try playbackConfiguration,
+ capabilities: capabilities,
+ automaticallySyncSubtitles: isEnabled
+ )
+
+ let queryItems = try capturedQueryItems(capture)
+ #expect(queryItems.contains {
+ $0.name == "autoAdjustSubtitle" && $0.value == expectedValue
+ })
+ #expect(plan.supportsSubtitleAutoSync == canAutoSync)
+ }
+
+ @Test func musicDecisionOmitsVideoSubtitleParameters() async throws {
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, transcodeDecisionData())
+ }
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "track",
+ "title": "Track",
+ "type": "track",
+ "Media": [{
+ "container": "flac",
+ "audioCodec": "flac",
+ "Part": [{"id": "701", "key": "/library/parts/701/file.flac"}]
+ }]
+ }
+ """#)
+
+ _ = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try playbackConfiguration,
+ capabilities: capabilities
+ )
+
+ #expect(try !capturedQueryItems(capture).contains {
+ $0.name == "autoAdjustSubtitle"
+ })
+ #expect(try !capturedQueryItems(capture).contains {
+ $0.name == "subtitleSize"
+ })
+ }
+
+ @Test(arguments: [
+ (false, "0"),
+ (true, "1"),
+ ])
+ func automaticQualityMapsExactlyToPlexDecisionFlag(
+ isEnabled: Bool,
+ expectedValue: String
+ ) async throws {
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, transcodeDecisionData())
+ }
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "42",
+ "title": "Adaptive Movie",
+ "type": "movie",
+ "Media": [{
+ "videoCodec": "h264",
+ "width": "1920",
+ "height": "1080",
+ "bitrate": "8000",
+ "Part": [{"key": "/library/parts/7/file.mp4"}]
+ }]
+ }
+ """#)
+
+ _ = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try playbackConfiguration,
+ capabilities: capabilities,
+ automaticallyAdjustVideoQuality: isEnabled
+ )
+
+ let queryItems = try capturedQueryItems(capture)
+ #expect(queryItems.contains {
+ $0.name == "autoAdjustQuality" && $0.value == expectedValue
+ })
+ }
+
+ @Test func forcedAdaptiveConversionDisablesEveryOriginalVideoPath() async throws {
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, transcodeDecisionData())
+ }
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "42",
+ "title": "Convert This Movie",
+ "type": "movie",
+ "Media": [{
+ "container": "mp4",
+ "videoCodec": "h264",
+ "audioCodec": "aac",
+ "width": "1920",
+ "height": "1080",
+ "bitrate": "8000",
+ "Part": [{
+ "container": "mp4",
+ "key": "/library/parts/7/file.mp4",
+ "Stream": [
+ {"streamType": "1", "codec": "h264", "selected": "1"},
+ {"streamType": "2", "codec": "aac", "selected": "1"}
+ ]
+ }]
+ }]
+ }
+ """#)
+
+ _ = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try playbackConfiguration,
+ capabilities: capabilities,
+ streamingPolicy: PlexPlaybackStreamingPolicy(
+ allowsDirectPlay: true,
+ allowsDirectStream: true,
+ forceDirectPlay: true
+ ),
+ automaticallyAdjustVideoQuality: true,
+ forceVideoTranscode: true
+ )
+
+ let queryItems = try capturedQueryItems(capture)
+ #expect(queryItems.contains { $0.name == "directPlay" && $0.value == "0" })
+ #expect(queryItems.contains { $0.name == "directStream" && $0.value == "0" })
+ #expect(queryItems.contains { $0.name == "directStreamAudio" && $0.value == "0" })
+ #expect(queryItems.contains { $0.name == "autoAdjustQuality" && $0.value == "1" })
+ }
+
+ @Test func forcedVideoConversionDoesNotChangeMusicDecisions() async throws {
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, directPlayDecisionData())
+ }
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "42",
+ "title": "Keep This Track Original",
+ "type": "track",
+ "Media": [{
+ "container": "mp4",
+ "audioCodec": "aac",
+ "bitrate": "256",
+ "Part": [{"key": "/library/parts/7/track.m4a"}]
+ }]
+ }
+ """#)
+
+ _ = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try playbackConfiguration,
+ capabilities: capabilities,
+ automaticallyAdjustVideoQuality: true,
+ forceVideoTranscode: true
+ )
+
+ let queryItems = try capturedQueryItems(capture)
+ #expect(queryItems.contains { $0.name == "directPlay" && $0.value == "1" })
+ #expect(queryItems.contains { $0.name == "directStream" && $0.value == "1" })
+ #expect(queryItems.contains { $0.name == "directStreamAudio" && $0.value == "1" })
+ #expect(!queryItems.contains { $0.name == "autoAdjustQuality" })
+ }
+
+ @Test func forceDirectPlayUsesAnExactNativeSinglePartWithoutRequestingADecision() async throws {
+ let session = makeMediaMockSession { _ in
+ Issue.record("A native-safe forced direct play must not request a PMS decision")
+ throw URLError(.badServerResponse)
+ }
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "42",
+ "title": "Native File",
+ "type": "movie",
+ "duration": "5400000",
+ "Media": [{
+ "container": "mp4",
+ "videoCodec": "h264",
+ "audioCodec": "aac",
+ "Part": [{
+ "container": "mp4",
+ "key": "/library/parts/7/native-file.mp4",
+ "Stream": [
+ {"streamType": "1", "codec": "h264", "selected": "1"},
+ {"streamType": "2", "codec": "aac", "selected": "1"}
+ ]
+ }]
+ }]
+ }
+ """#)
+
+ let plan = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try playbackConfiguration,
+ capabilities: capabilities,
+ streamingPolicy: PlexPlaybackStreamingPolicy(
+ allowsDirectPlay: true,
+ allowsDirectStream: true,
+ forceDirectPlay: true
+ )
+ )
+
+ let components = try #require(URLComponents(url: plan.url, resolvingAgainstBaseURL: false))
+ #expect(plan.method == .directPlay)
+ #expect(plan.source == PlexPlaybackSource(mediaIndex: 0, partIndex: 0))
+ #expect(components.path == "/library/parts/7/native-file.mp4")
+ #expect(components.queryItems?.contains { $0.name == "X-Plex-Token" && $0.value == "server-token" } == true)
+ #expect(components.queryItems?.contains { $0.name == "X-Plex-Session-Identifier" } == true)
+ #expect(components.queryItems?.contains { $0.name == "directPlay" } == false)
+ }
+
+ @Test func forceDirectPlayUsesAnExactNativeMusicPair() async throws {
+ let session = makeMediaMockSession { _ in
+ Issue.record("A native-safe music file must not request a PMS decision")
+ throw URLError(.badServerResponse)
+ }
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "track-42",
+ "title": "Native Track",
+ "type": "track",
+ "Media": [{
+ "container": "mp3",
+ "audioCodec": "mp3",
+ "Part": [{
+ "key": "/library/parts/8/native-track.mp3",
+ "Stream": [{"streamType": "2", "codec": "mp3", "selected": "1"}]
+ }]
+ }]
+ }
+ """#)
+
+ let plan = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try playbackConfiguration,
+ capabilities: capabilities,
+ streamingPolicy: PlexPlaybackStreamingPolicy(
+ allowsDirectPlay: true,
+ allowsDirectStream: true,
+ forceDirectPlay: true
+ )
+ )
+
+ #expect(plan.method == .directPlay)
+ #expect(plan.mediaKind == .music)
+ #expect(plan.url.path == "/library/parts/8/native-track.mp3")
+ }
+
+ @Test func forceDirectPlayFallsBackToPMSWhenTheNativeContractIsNotProven() async throws {
+ let unsafeItems = try [
+ decodeItem(#"""
+ {
+ "ratingKey": "mkv",
+ "title": "Unsupported Container",
+ "type": "movie",
+ "Media": [{
+ "container": "mkv",
+ "videoCodec": "h264",
+ "audioCodec": "aac",
+ "Part": [{"key": "/library/parts/1/file.mkv"}]
+ }]
+ }
+ """#),
+ decodeItem(#"""
+ {
+ "ratingKey": "missing-facts",
+ "title": "Missing Codec Facts",
+ "type": "movie",
+ "Media": [{
+ "container": "mp4",
+ "Part": [{"key": "/library/parts/2/file.mp4"}]
+ }]
+ }
+ """#),
+ decodeItem(#"""
+ {
+ "ratingKey": "multipart",
+ "title": "Multipart Movie",
+ "type": "movie",
+ "Media": [{
+ "container": "mp4",
+ "videoCodec": "h264",
+ "audioCodec": "aac",
+ "Part": [
+ {"key": "/library/parts/3/disc-1.mp4"},
+ {"key": "/library/parts/4/disc-2.mp4"}
+ ]
+ }]
+ }
+ """#),
+ decodeItem(#"""
+ {
+ "ratingKey": "subtitle",
+ "title": "Selected Subtitle",
+ "type": "movie",
+ "Media": [{
+ "container": "mp4",
+ "videoCodec": "h264",
+ "audioCodec": "aac",
+ "Part": [{
+ "key": "/library/parts/5/file.mp4",
+ "Stream": [{
+ "streamType": "3",
+ "codec": "ass",
+ "selected": "1"
+ }]
+ }]
+ }]
+ }
+ """#),
+ ]
+
+ for item in unsafeItems {
+ let source = try #require(item.defaultPlaybackSource)
+ #expect(capabilities.directPlayPath(for: item, source: source) == nil)
+ }
+ }
+
+ @Test func forceDirectPlayDefersToQualityAndExplicitStreamSelection() async throws {
+ let item = try decodeItem(#"""
+ {
+ "ratingKey": "42",
+ "title": "Large Native File",
+ "type": "movie",
+ "Media": [{
+ "container": "mp4",
+ "videoCodec": "h264",
+ "audioCodec": "aac",
+ "width": "3840",
+ "height": "2160",
+ "bitrate": "48000",
+ "Part": [{"key": "/library/parts/7/file.mp4"}]
+ }]
+ }
+ """#)
+ let policy = PlexPlaybackStreamingPolicy(
+ allowsDirectPlay: true,
+ allowsDirectStream: true,
+ forceDirectPlay: true
+ )
+
+ for requestContract in [
+ (PlexVideoQuality.fullHD8Mbps, false),
+ (PlexVideoQuality.original, true),
+ ] {
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, transcodeDecisionData())
+ }
+
+ _ = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try playbackConfiguration,
+ capabilities: capabilities,
+ videoQuality: requestContract.0,
+ streamingPolicy: policy,
+ forceServerMediaSelection: requestContract.1
+ )
+
+ let request = try #require(capture.request)
+ let queryItems = try capturedQueryItems(capture)
+ #expect(request.url?.path == "/video/:/transcode/universal/decision")
+ #expect(queryItems.contains { $0.name == "directPlay" && $0.value == "0" })
+ }
+ }
+
+ @Test func disablingDirectPlayTakesPrecedenceOverForceDirectPlay() async throws {
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, transcodeDecisionData())
+ }
+ let item = try JSONDecoder().decode(PlexMediaItem.self, from: playableItemData())
+
+ _ = try await PlexAPIClient(session: session).makePlaybackPlan(
+ for: item,
+ using: try playbackConfiguration,
+ capabilities: capabilities,
+ streamingPolicy: PlexPlaybackStreamingPolicy(
+ allowsDirectPlay: false,
+ allowsDirectStream: true,
+ forceDirectPlay: true
+ )
+ )
+
+ #expect(try capturedQueryItems(capture).contains {
+ $0.name == "directPlay" && $0.value == "0"
+ })
+ }
+
+ @Test func streamSelectionUsesTheDocumentedPartEndpoint() async throws {
+ let capture = RequestCapture()
+ let session = makeMediaMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, Data())
+ }
+
+ try await PlexAPIClient(session: session).selectMediaStreams(
+ partID: 700,
+ audioStreamID: 21,
+ subtitleStreamID: 33,
+ allParts: true,
+ using: try playbackConfiguration
+ )
+
+ let request = try #require(capture.request)
+ let components = try #require(
+ request.url.flatMap { URLComponents(url: $0, resolvingAgainstBaseURL: false) }
+ )
+ #expect(request.httpMethod == "PUT")
+ #expect(components.path == "/library/parts/700")
+ #expect(components.queryItems?.contains { $0.name == "audioStreamID" && $0.value == "21" } == true)
+ #expect(components.queryItems?.contains { $0.name == "subtitleStreamID" && $0.value == "33" } == true)
+ #expect(components.queryItems?.contains { $0.name == "allParts" && $0.value == "1" } == true)
+ }
+
+ private var playbackConfiguration: PlexConnectionConfiguration {
+ get throws {
+ PlexConnectionConfiguration(
+ serverURL: try #require(PlexURLBuilder.normalizeServerURL("https://plex.local:32400")),
+ token: "server-token",
+ clientContext: PlexClientContext(clientIdentifier: "client-123")
+ )
+ }
+ }
+
+ private var capabilities: PlexPlaybackCapabilities {
+ PlexPlaybackCapabilities(
+ directPlayContainers: ["mp4"],
+ directPlayVideoCodecs: ["h264", "hevc"],
+ directPlayAudioCodecs: ["aac"],
+ directPlayMusicProfiles: [
+ PlexMusicDirectPlayProfile(container: "mp3", audioCodec: "mp3"),
+ PlexMusicDirectPlayProfile(container: "mp4", audioCodec: "aac"),
+ ]
+ )
+ }
+
+ private func decodeItem(_ json: String) throws -> PlexMediaItem {
+ try JSONDecoder().decode(PlexMediaItem.self, from: Data(json.utf8))
+ }
+
+ private func capturedQueryItems(_ capture: RequestCapture) throws -> [URLQueryItem] {
+ let request = try #require(capture.request)
+ let components = try #require(
+ request.url.flatMap { URLComponents(url: $0, resolvingAgainstBaseURL: false) }
+ )
+ return components.queryItems ?? []
+ }
+}
diff --git a/PlexBarTests/PlexPlaybackSleepTimerTests.swift b/PlexBarTests/PlexPlaybackSleepTimerTests.swift
new file mode 100644
index 0000000..cfa8164
--- /dev/null
+++ b/PlexBarTests/PlexPlaybackSleepTimerTests.swift
@@ -0,0 +1,37 @@
+import Foundation
+import Testing
+@testable import PlexBar
+
+struct PlexPlaybackSleepTimerTests {
+ @Test func timedPresetUsesAnAbsoluteDeadline() throws {
+ let start = Date(timeIntervalSince1970: 1_000)
+ let timer = PlexPlaybackSleepTimer(
+ preset: .thirtyMinutes,
+ startingAt: start
+ )
+
+ #expect(timer.isActive)
+ #expect(!timer.stopsAtEndOfItem)
+ #expect(timer.deadline == start.addingTimeInterval(30 * 60))
+ #expect(timer.remainingTime(at: start.addingTimeInterval(300)) == 1_500)
+ #expect(!timer.hasExpired(at: start.addingTimeInterval(1_799)))
+ #expect(timer.hasExpired(at: start.addingTimeInterval(1_800)))
+ #expect(timer.remainingTime(at: start.addingTimeInterval(1_900)) == 0)
+ }
+
+ @Test func endOfItemHasNoWallClockDeadline() {
+ let timer = PlexPlaybackSleepTimer(preset: .endOfItem)
+
+ #expect(timer.isActive)
+ #expect(timer.stopsAtEndOfItem)
+ #expect(timer.deadline == nil)
+ #expect(timer.remainingTime() == nil)
+ #expect(!timer.hasExpired())
+ }
+
+ @Test func offTimerIsInactive() {
+ #expect(!PlexPlaybackSleepTimer.off.isActive)
+ #expect(!PlexPlaybackSleepTimer.off.stopsAtEndOfItem)
+ #expect(PlexPlaybackSleepTimer.off.deadline == nil)
+ }
+}
diff --git a/PlexBarTests/PlexPlayerInfoTests.swift b/PlexBarTests/PlexPlayerInfoTests.swift
new file mode 100644
index 0000000..53078e0
--- /dev/null
+++ b/PlexBarTests/PlexPlayerInfoTests.swift
@@ -0,0 +1,274 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+struct PlexPlayerInfoTests {
+ @Test func playbackVersionsUseSharedExactSourcesAndHumanReadableFacts() throws {
+ let movie = try JSONDecoder().decode(
+ PlexMediaItem.self,
+ from: Data(#"""
+ {
+ "ratingKey": "42",
+ "type": "movie",
+ "title": "Blade Runner",
+ "Media": [
+ {
+ "width": 1920,
+ "height": 1080,
+ "videoCodec": "h264",
+ "bitrate": 12000,
+ "container": "mkv",
+ "Part": [{ "key": "/library/parts/1/movie.mkv" }]
+ },
+ {
+ "videoResolution": "4k",
+ "videoCodec": "hevc",
+ "bitrate": 48720,
+ "container": "mkv",
+ "Part": [{ "key": "/library/parts/2/movie.mkv" }]
+ },
+ {
+ "videoResolution": "sd",
+ "videoCodec": "h264",
+ "container": "mp4"
+ }
+ ]
+ }
+ """#.utf8)
+ )
+
+ let selection = try #require(PlexPlaybackVersionSelection(
+ item: movie,
+ selectedSource: PlexPlaybackSource(mediaIndex: 1, partIndex: 0)
+ ))
+
+ #expect(selection.options.map(\.id) == [0, 1])
+ #expect(selection.options.map(\.label) == [
+ "Version 1 · 1920 × 1080 · H264 · 12 Mbps · MKV",
+ "Version 2 · 4K · HEVC · 48.7 Mbps · MKV",
+ ])
+ #expect(selection.selectedID == 1)
+ #expect(selection.selectedOption?.id == 1)
+ #expect(selection.source(for: 0) == PlexPlaybackSource(mediaIndex: 0, partIndex: 0))
+ #expect(selection.source(for: 1) == nil)
+ #expect(selection.source(for: 2) == nil)
+ }
+
+ @Test func playbackVersionMenuRequiresMultiplePlayableVersions() throws {
+ let track = try JSONDecoder().decode(
+ PlexMediaItem.self,
+ from: Data(#"""
+ {
+ "ratingKey": "84",
+ "type": "track",
+ "title": "Roads",
+ "Media": [{
+ "audioCodec": "flac",
+ "bitrate": 921,
+ "container": "flac",
+ "Part": [{ "key": "/library/parts/3/roads.flac" }]
+ }]
+ }
+ """#.utf8)
+ )
+ let source = try #require(track.defaultPlaybackSource)
+
+ #expect(track.playbackVersionOptions.map(\.label) == [
+ "Version 1 · FLAC · 921 kbps · FLAC",
+ ])
+ #expect(PlexPlaybackVersionSelection(item: track, selectedSource: source) == nil)
+ }
+
+ @Test func playbackInfoUsesOnlyCurrentItemIdentityAndHierarchy() throws {
+ let episode = try JSONDecoder().decode(
+ PlexMediaItem.self,
+ from: Data(#"""
+ {
+ "ratingKey": "42",
+ "type": "episode",
+ "title": "Good News About Hell",
+ "grandparentTitle": "Severance",
+ "parentTitle": "Season 1",
+ "parentIndex": 1,
+ "index": 1,
+ "year": 2022,
+ "duration": 3420000,
+ "summary": " Mark starts a new job. ",
+ "contentRating": " TV-MA ",
+ "Genre": [
+ { "tag": "Drama" },
+ { "tag": "Thriller" },
+ { "tag": "Drama" },
+ { "tag": " " }
+ ]
+ }
+ """#.utf8)
+ )
+
+ let presentation = PlexPlayerPlaybackInfoPresentation(item: episode)
+
+ #expect(presentation.title == "Good News About Hell")
+ #expect(presentation.hierarchyLine == "Severance · Season 1")
+ #expect(presentation.summary == "Mark starts a new job.")
+ #expect(presentation.contentRating == "TV-MA")
+ #expect(presentation.genre == "Drama, Thriller")
+ }
+
+ @Test func playbackInfoRemovesRepeatedHierarchyLabels() throws {
+ let track = try JSONDecoder().decode(
+ PlexMediaItem.self,
+ from: Data(#"""
+ {
+ "ratingKey": "84",
+ "type": "track",
+ "title": "Chapter 1",
+ "grandparentTitle": "The Author",
+ "parentTitle": "The Author",
+ "summary": " "
+ }
+ """#.utf8)
+ )
+
+ let presentation = PlexPlayerPlaybackInfoPresentation(item: track)
+
+ #expect(presentation.hierarchyLine == "The Author")
+ #expect(presentation.summary == nil)
+ #expect(presentation.contentRating == nil)
+ #expect(presentation.genre == nil)
+ }
+
+ @Test func playbackInfoBuildsStableSharedPlaybackRows() throws {
+ let movie = try JSONDecoder().decode(
+ PlexMediaItem.self,
+ from: Data(#"{"ratingKey":"9","type":"movie","title":"Arrival"}"#.utf8)
+ )
+
+ var metrics = PlexPlaybackMetricFacts()
+ metrics.recordStall()
+
+ let presentation = PlexPlaybackInfoPresentation(
+ item: movie,
+ deliveryLabel: "Direct Stream",
+ connectionLabel: "Remote",
+ videoQualityLabel: "1080p · 12 Mbps",
+ playbackVersionLabel: "Version 2 · 4K · HEVC",
+ queuePositionLabel: "2 of 8",
+ waitingReasonLabel: "Minimizing Stalls",
+ audioOutputLabel: "Dolby Atmos",
+ deliveredMediaFacts: nil,
+ playbackMetricFacts: metrics.diagnosticFacts
+ )
+
+ #expect(presentation.playbackRows.map(\.id) == [
+ "delivery",
+ "connection",
+ "quality",
+ "version",
+ "queue",
+ "waiting",
+ ])
+ #expect(presentation.playbackRows.map(\.value) == [
+ "Direct Stream",
+ "Remote",
+ "1080p · 12 Mbps",
+ "Version 2 · 4K · HEVC",
+ "2 of 8",
+ "Minimizing Stalls",
+ ])
+ #expect(presentation.videoRows.isEmpty)
+ #expect(presentation.audioRows.map(\.id) == ["output"])
+ #expect(presentation.audioRows.map(\.value) == ["Dolby Atmos"])
+ #expect(presentation.performanceRows.map(\.id) == ["metric.stalls"])
+ #expect(presentation.performanceRows.map(\.value) == ["1"])
+ }
+
+ @Test func playerUsesOneMutuallyExclusiveInPlayerOverlay() {
+ var selection = PlexPlayerOverlaySelection()
+
+ #expect(!selection.isPresented)
+ #expect(selection.selected == nil)
+
+ selection.toggle(.info)
+ #expect(selection.isPresented)
+ #expect(selection.selected == .info)
+
+ selection.toggle(.upNext)
+ #expect(selection.selected == .upNext)
+
+ selection.toggle(.upNext)
+ #expect(!selection.isPresented)
+
+ selection.toggle(.info)
+ selection.dismiss()
+ #expect(selection.selected == nil)
+ }
+
+ @Test func playerOverlayCommandsDescribeTheActionTheyWillPerform() {
+ var selection = PlexPlayerOverlaySelection()
+
+ #expect(selection.commandTitle(for: .info) == "Show Playback Info")
+ #expect(selection.commandTitle(for: .upNext) == "Show Up Next")
+
+ selection.present(.info)
+ #expect(selection.commandTitle(for: .info) == "Hide Playback Info")
+ #expect(selection.commandTitle(for: .upNext) == "Show Up Next")
+
+ selection.present(.upNext)
+ #expect(selection.commandTitle(for: .info) == "Show Playback Info")
+ #expect(selection.commandTitle(for: .upNext) == "Hide Up Next")
+ }
+
+ @Test func currentItemMutationTicketRejectsAQueueTransition() throws {
+ let current = try playbackPresentation(
+ ratingKey: "42",
+ title: "Current",
+ sessionIdentifier: "session-current"
+ )
+ let ticket = PlexPlayerItemMutationTicket(presentation: current)
+
+ #expect(ticket.accepts(current))
+ #expect(!ticket.accepts(try playbackPresentation(
+ ratingKey: "43",
+ title: "Next",
+ sessionIdentifier: "session-next"
+ )))
+ #expect(!ticket.accepts(try playbackPresentation(
+ ratingKey: "42",
+ title: "Repeated Later",
+ sessionIdentifier: "session-repeat"
+ )))
+ }
+
+ private func playbackPresentation(
+ ratingKey: String,
+ title: String,
+ sessionIdentifier: String
+ ) throws -> PlexPlaybackPresentation {
+ let item = try JSONDecoder().decode(
+ PlexMediaItem.self,
+ from: Data(#"{"ratingKey":"\#(ratingKey)","title":"\#(title)","type":"movie"}"#.utf8)
+ )
+ let source = PlexPlaybackSource(
+ mediaIndex: 0,
+ partIndex: 0
+ )
+ let plan = PlexPlaybackPlan(
+ url: URL(string: "https://example.com/video.mkv")!,
+ method: .directPlay,
+ mediaKind: .video,
+ sessionIdentifier: sessionIdentifier,
+ ratingKey: ratingKey,
+ duration: nil,
+ startTime: 0,
+ source: source,
+ usesServerMediaSelection: false
+ )
+ return PlexPlaybackPresentation(
+ item: item,
+ plan: plan,
+ queue: nil,
+ videoQuality: .original
+ )
+ }
+}
diff --git a/PlexBarTests/PlexRelatedContentTests.swift b/PlexBarTests/PlexRelatedContentTests.swift
new file mode 100644
index 0000000..1512edd
--- /dev/null
+++ b/PlexBarTests/PlexRelatedContentTests.swift
@@ -0,0 +1,565 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+@Suite(.serialized)
+struct PlexRelatedContentTests {
+ @Test func requestUsesDocumentedMetadataRelatedEndpointAndDecodesNonemptyHubs() async throws {
+ let capture = RequestCapture()
+ let session = makeRelatedContentMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, Self.relatedHubsData(ratingKey: "42"))
+ }
+
+ let hubs = try await PlexAPIClient(session: session).fetchRelatedHubs(
+ ratingKey: "42",
+ using: try configuration
+ )
+
+ let request = try #require(capture.request)
+ #expect(request.httpMethod == "GET")
+ #expect(request.url?.path == "/hubs/metadata/42/related")
+ #expect(request.url?.query == "count=12")
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Token") == "server-token")
+ #expect(hubs.map(\.title) == ["Related Movies"])
+ #expect(hubs.first?.metadata.map(\.title) == ["Related to 42"])
+ }
+
+ @Test func postPlayUsesDocumentedEndpointAndPreservesServerHubOrdering() async throws {
+ let capture = RequestCapture()
+ let session = makeRelatedContentMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (
+ response,
+ Data(#"{"MediaContainer":{"Hub":[{"hubIdentifier":"postplay.next","title":"Up Next","Metadata":[{"ratingKey":"43","type":"episode","title":"Episode 2"}]},{"hubIdentifier":"postplay.empty","title":"Empty","Metadata":[]},{"hubIdentifier":"postplay.related","title":"Related","Metadata":[{"ratingKey":"44","type":"movie","title":"Another Movie"}]}]}}"#.utf8)
+ )
+ }
+
+ let hubs = try await PlexAPIClient(session: session).fetchPostPlayHubs(
+ ratingKey: "42",
+ using: try configuration,
+ count: 8
+ )
+
+ let request = try #require(capture.request)
+ #expect(request.httpMethod == "GET")
+ #expect(request.url?.path == "/hubs/metadata/42/postplay")
+ #expect(request.url?.query == "count=8")
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Token") == "server-token")
+ #expect(hubs.map(\.title) == ["Up Next", "Related"])
+ #expect(hubs.flatMap(\.metadata).map(\.ratingKey) == ["43", "44"])
+ }
+
+ @Test func postPlayRejectsNonPMSMetadataIdentityBeforeSendingARequest() async throws {
+ let capture = RequestCapture()
+ let session = makeRelatedContentMockSession { request in
+ capture.record(request)
+ throw URLError(.unsupportedURL)
+ }
+
+ await #expect(throws: PlexAPIError.self) {
+ try await PlexAPIClient(session: session).fetchPostPlayHubs(
+ ratingKey: "provider-item",
+ using: try configuration
+ )
+ }
+ #expect(capture.request == nil)
+ }
+
+ @MainActor
+ @Test func failedRefreshKeepsExistingRelatedShelvesVisible() async throws {
+ let scenario = RelatedContentScenario()
+ let suiteName = "PlexBarTests.failedRelatedContentRefresh"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ let store = try makeStore(defaults: defaults, scenario: scenario)
+ let item = try decodeItem(ratingKey: "42")
+
+ await store.loadRelatedContent(for: item)
+ #expect(store.relatedHubs(for: item).first?.metadata.map(\.title) == ["Related to 42"])
+ #expect(store.relatedContentErrorMessage(for: item) == nil)
+
+ scenario.failRequests(for: "42")
+ await store.loadRelatedContent(for: item, forceRefresh: true)
+
+ #expect(store.relatedHubs(for: item).first?.metadata.map(\.title) == ["Related to 42"])
+ #expect(store.relatedContentErrorMessage(for: item) != nil)
+ #expect(store.hasLoadedRelatedContent(for: item))
+ }
+
+ @MainActor
+ @Test func relatedContentCacheIsLeastRecentlyUsedAndRouteResolvable() async throws {
+ let scenario = RelatedContentScenario()
+ let suiteName = "PlexBarTests.relatedContentCacheIsBounded"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ let store = try makeStore(
+ defaults: defaults,
+ scenario: scenario,
+ relatedContentLimit: 2
+ )
+ let first = try decodeItem(ratingKey: "1")
+ let second = try decodeItem(ratingKey: "2")
+ let third = try decodeItem(ratingKey: "3")
+
+ await store.loadRelatedContent(for: first)
+ await store.loadRelatedContent(for: second)
+ let firstHub = try #require(store.relatedHubs(for: first).first)
+ let firstRoute = try #require(PlexRelatedHubRoute(sourceItem: first, hub: firstHub))
+ await store.loadRelatedHubItems(for: firstRoute)
+ await store.loadRelatedContent(for: third)
+
+ #expect(store.hasLoadedRelatedContent(for: first))
+ #expect(!store.hasLoadedRelatedContent(for: second))
+ #expect(store.hasLoadedRelatedContent(for: third))
+ #expect(store.relatedContentState.recency == ["1", "3"])
+
+ let relatedItem = try #require(store.relatedHubItems(for: firstRoute).first)
+ #expect(store.item(for: PlexMediaRoute(item: relatedItem)) == relatedItem)
+
+ store.resetRelatedContent()
+ #expect(store.relatedContentState.recency.isEmpty)
+ #expect(store.relatedHubs(for: first).isEmpty)
+ #expect(store.item(for: PlexMediaRoute(item: relatedItem)) == nil)
+ }
+
+ @MainActor
+ @Test func relatedShowAllFollowsTheExactReturnedKeyAndPaginates() async throws {
+ let scenario = RelatedContentScenario()
+ let suiteName = "PlexBarTests.relatedShowAllPagination"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ let store = try makeStore(defaults: defaults, scenario: scenario, pageSize: 2)
+ let item = try decodeItem(ratingKey: "42")
+
+ await store.loadRelatedContent(for: item)
+ let hub = try #require(store.relatedHubs(for: item).first)
+ let route = try #require(PlexRelatedHubRoute(sourceItem: item, hub: hub))
+ #expect(route.hubKey == "/library/sections/1/all?type=1&relatedTo=42")
+ #expect(store.relatedHub(for: route) == hub)
+
+ await store.loadRelatedHubItems(for: route)
+ let lastItem = try #require(store.relatedHubItems(for: route).last)
+ await store.loadMoreRelatedHubItemsIfNeeded(for: route, currentItem: lastItem)
+
+ #expect(store.relatedHubItems(for: route).map(\.title) == [
+ "Related Page 1 for 42",
+ "Related Page 2 for 42",
+ "Related Page 3 for 42"
+ ])
+ #expect(!store.hasMoreRelatedHubItems(for: route))
+
+ let requests = scenario.capturedPageRequests()
+ #expect(requests.count == 2)
+ #expect(requests.allSatisfy { $0.url?.path == "/library/sections/1/all" })
+ #expect(requests.allSatisfy { request in
+ guard let components = request.url.flatMap({
+ URLComponents(url: $0, resolvingAgainstBaseURL: false)
+ }) else {
+ return false
+ }
+ return components.queryItems == [
+ URLQueryItem(name: "type", value: "1"),
+ URLQueryItem(name: "relatedTo", value: "42")
+ ]
+ })
+ #expect(requests.map { $0.value(forHTTPHeaderField: "X-Plex-Container-Start") } == ["0", "2"])
+ #expect(requests.map { $0.value(forHTTPHeaderField: "X-Plex-Container-Size") } == ["2", "2"])
+ #expect(requests.allSatisfy {
+ $0.value(forHTTPHeaderField: "X-Plex-Token") == "server-token"
+ })
+
+ let pagedItem = try #require(store.relatedHubItems(for: route).last)
+ #expect(store.item(for: PlexMediaRoute(item: pagedItem)) == pagedItem)
+ }
+
+ @MainActor
+ @Test func failedRelatedShowAllRefreshKeepsExistingItemsVisible() async throws {
+ let scenario = RelatedContentScenario()
+ let suiteName = "PlexBarTests.failedRelatedShowAllRefresh"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ let store = try makeStore(defaults: defaults, scenario: scenario, pageSize: 2)
+ let item = try decodeItem(ratingKey: "42")
+
+ await store.loadRelatedContent(for: item)
+ let hub = try #require(store.relatedHubs(for: item).first)
+ let route = try #require(PlexRelatedHubRoute(sourceItem: item, hub: hub))
+ await store.loadRelatedHubItems(for: route)
+
+ scenario.failPageRequests()
+ await store.loadRelatedHubItems(for: route, forceRefresh: true)
+
+ #expect(store.relatedHubItems(for: route).map(\.title) == [
+ "Related Page 1 for 42",
+ "Related Page 2 for 42"
+ ])
+ #expect(store.relatedHubItemsErrorMessage(for: route) != nil)
+ }
+
+ @MainActor
+ @Test func relatedCacheEvictionRemovesExpandedPagesAndRoutes() async throws {
+ let scenario = RelatedContentScenario()
+ let suiteName = "PlexBarTests.relatedExpandedCacheEviction"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ let store = try makeStore(
+ defaults: defaults,
+ scenario: scenario,
+ relatedContentLimit: 2,
+ pageSize: 2
+ )
+ let first = try decodeItem(ratingKey: "1")
+ let second = try decodeItem(ratingKey: "2")
+ let third = try decodeItem(ratingKey: "3")
+
+ await store.loadRelatedContent(for: first)
+ let firstHub = try #require(store.relatedHubs(for: first).first)
+ let firstRoute = try #require(PlexRelatedHubRoute(sourceItem: first, hub: firstHub))
+ await store.loadRelatedHubItems(for: firstRoute)
+ let expandedItem = try #require(store.relatedHubItems(for: firstRoute).first)
+
+ await store.loadRelatedContent(for: second)
+ await store.loadRelatedContent(for: third)
+
+ #expect(store.relatedHub(for: firstRoute) == nil)
+ #expect(store.relatedHubItems(for: firstRoute).isEmpty)
+ #expect(store.relatedContentState.itemsByHubRoute[firstRoute] == nil)
+ #expect(store.item(for: PlexMediaRoute(item: expandedItem)) == nil)
+ }
+
+ @MainActor
+ @Test func serverConfirmedMutationsUpdateExpandedRelatedResults() async throws {
+ let scenario = RelatedContentScenario()
+ let suiteName = "PlexBarTests.relatedExpandedMutationPropagation"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ let store = try makeStore(defaults: defaults, scenario: scenario, pageSize: 2)
+ let item = try decodeItem(ratingKey: "42")
+
+ await store.loadRelatedContent(for: item)
+ let hub = try #require(store.relatedHubs(for: item).first)
+ let route = try #require(PlexRelatedHubRoute(sourceItem: item, hub: hub))
+ await store.loadRelatedHubItems(for: route)
+
+ let refreshed = try JSONDecoder().decode(
+ PlexMediaItem.self,
+ from: Data(#"{"ratingKey":"page-42-1","type":"movie","title":"Related Page 1 for 42","viewCount":1,"userRating":9}"#.utf8)
+ )
+ store.replaceCachedRelatedWatchedState(with: refreshed)
+ store.replaceCachedRelatedUserRating(with: refreshed)
+
+ let updated = try #require(store.relatedHubItems(for: route).first)
+ #expect(updated.isWatched)
+ #expect(updated.userRating == 9)
+ }
+
+ @MainActor
+ @Test func stalePageResponseCannotRepopulateAResetAndReloadedRelatedRoute() async throws {
+ let scenario = RelatedContentScenario()
+ let suiteName = "PlexBarTests.staleRelatedPageResponse"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ let store = try makeStore(defaults: defaults, scenario: scenario, pageSize: 2)
+ let item = try decodeItem(ratingKey: "42")
+ await store.loadRelatedContent(for: item)
+ let originalHub = try #require(store.relatedHubs(for: item).first)
+ let originalRoute = try #require(PlexRelatedHubRoute(sourceItem: item, hub: originalHub))
+ let gate = RelatedPageLoadGate()
+ let stalePage = PlexMediaPage(
+ items: [try decodeItem(ratingKey: "stale")],
+ offset: 0,
+ totalSize: 1
+ )
+
+ let staleLoad = Task {
+ await store.loadRelatedHubItems(for: originalRoute) { path, start in
+ #expect(path == originalRoute.hubKey)
+ #expect(start == 0)
+ await gate.suspendLoad()
+ return stalePage
+ }
+ }
+ await gate.waitUntilLoadStarts()
+
+ store.resetRelatedContent()
+ await store.loadRelatedContent(for: item)
+ let reloadedHub = try #require(store.relatedHubs(for: item).first)
+ let reloadedRoute = try #require(PlexRelatedHubRoute(sourceItem: item, hub: reloadedHub))
+ #expect(reloadedRoute == originalRoute)
+
+ await gate.finishLoad()
+ await staleLoad.value
+
+ #expect(store.relatedContentState.itemsByHubRoute[reloadedRoute] == nil)
+ #expect(store.relatedHubItems(for: reloadedRoute).map(\.title) == ["Related to 42"])
+ }
+
+ private var configuration: PlexConnectionConfiguration {
+ get throws {
+ PlexConnectionConfiguration(
+ serverURL: try #require(URL(string: "https://plex.local:32400")),
+ token: "server-token",
+ clientContext: PlexClientContext(clientIdentifier: "client-123")
+ )
+ }
+ }
+
+ @MainActor
+ private func makeStore(
+ defaults: UserDefaults,
+ scenario: RelatedContentScenario,
+ relatedContentLimit: Int = 12,
+ pageSize: Int = 100
+ ) throws -> PlexBrowserStore {
+ let settings = PlexSettingsStore(
+ defaults: defaults,
+ credentialStore: PlexMemoryCredentialStore(
+ credentials: PlexStoredCredentials(
+ userToken: "user-token",
+ serverToken: "server-token"
+ )
+ ),
+ initialCredentials: PlexStoredCredentials(
+ userToken: "user-token",
+ serverToken: "server-token"
+ )
+ )
+ settings.selectedServerIdentifier = "server-id"
+ settings.selectedServerName = "Server"
+ let connectionStore = PlexConnectionStore(settings: settings)
+ connectionStore.activeConnection = PlexResolvedConnection(
+ serverID: "server-id",
+ url: try #require(URL(string: "https://plex.local:32400")),
+ kind: .local,
+ validatedAt: Date()
+ )
+ return PlexBrowserStore(
+ connectionStore: connectionStore,
+ client: PlexAPIClient(session: makeRelatedContentMockSession { request in
+ try scenario.response(for: request)
+ }),
+ pageSize: pageSize,
+ relatedContentLimit: relatedContentLimit
+ )
+ }
+
+ private func decodeItem(ratingKey: String) throws -> PlexMediaItem {
+ try JSONDecoder().decode(
+ PlexMediaItem.self,
+ from: Data(#"{"ratingKey":"\#(ratingKey)","type":"movie","title":"Item \#(ratingKey)"}"#.utf8)
+ )
+ }
+
+ fileprivate static func relatedHubsData(ratingKey: String) -> Data {
+ Data(#"""
+ {
+ "MediaContainer": {
+ "Hub": [{
+ "hubIdentifier": "related.movies.\#(ratingKey)",
+ "key": "/library/sections/1/all?type=1&relatedTo=\#(ratingKey)",
+ "title": "Related Movies",
+ "type": "movie",
+ "style": "shelf",
+ "size": 1,
+ "totalSize": 3,
+ "more": true,
+ "Metadata": [{
+ "ratingKey": "related-\#(ratingKey)",
+ "key": "/library/metadata/related-\#(ratingKey)",
+ "type": "movie",
+ "title": "Related to \#(ratingKey)"
+ }]
+ }, {
+ "hubIdentifier": "related.empty.\#(ratingKey)",
+ "title": "Empty",
+ "Metadata": []
+ }]
+ }
+ }
+ """#.utf8)
+ }
+
+ fileprivate static func relatedPageData(ratingKey: String, start: Int) -> Data {
+ let metadata: String
+ if start == 0 {
+ metadata = #"""
+ {"ratingKey":"page-\#(ratingKey)-1","key":"/library/metadata/page-\#(ratingKey)-1","type":"movie","title":"Related Page 1 for \#(ratingKey)","viewCount":0,"userRating":2},
+ {"ratingKey":"page-\#(ratingKey)-2","key":"/library/metadata/page-\#(ratingKey)-2","type":"movie","title":"Related Page 2 for \#(ratingKey)"}
+ """#
+ } else {
+ metadata = #"""
+ {"ratingKey":"page-\#(ratingKey)-3","key":"/library/metadata/page-\#(ratingKey)-3","type":"movie","title":"Related Page 3 for \#(ratingKey)"}
+ """#
+ }
+ return Data(#"""
+ {
+ "MediaContainer": {
+ "offset": \#(start),
+ "totalSize": 3,
+ "Metadata": [\#(metadata)]
+ }
+ }
+ """#.utf8)
+ }
+}
+
+private final class RelatedContentScenario: @unchecked Sendable {
+ private let lock = NSLock()
+ private var failingRatingKeys: Set = []
+ private var shouldFailPageRequests = false
+ private var pageRequests: [URLRequest] = []
+
+ func failRequests(for ratingKey: String) {
+ lock.lock()
+ failingRatingKeys.insert(ratingKey)
+ lock.unlock()
+ }
+
+ func failPageRequests() {
+ lock.lock()
+ shouldFailPageRequests = true
+ lock.unlock()
+ }
+
+ func capturedPageRequests() -> [URLRequest] {
+ lock.lock()
+ defer { lock.unlock() }
+ return pageRequests
+ }
+
+ func response(for request: URLRequest) throws -> (HTTPURLResponse, Data) {
+ let url = try #require(request.url)
+ if url.path == "/library/sections/1/all" {
+ let ratingKey = URLComponents(url: url, resolvingAgainstBaseURL: false)?
+ .queryItems?
+ .first(where: { $0.name == "relatedTo" })?
+ .value ?? ""
+ let start = Int(request.value(forHTTPHeaderField: "X-Plex-Container-Start") ?? "0") ?? 0
+
+ lock.lock()
+ pageRequests.append(request)
+ let shouldFail = shouldFailPageRequests
+ lock.unlock()
+ if shouldFail {
+ throw PlexAPIError.invalidResponse
+ }
+
+ let response = try #require(HTTPURLResponse(
+ url: url,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: ["X-Plex-Container-Total-Size": "3"]
+ ))
+ return (
+ response,
+ PlexRelatedContentTests.relatedPageData(ratingKey: ratingKey, start: start)
+ )
+ }
+
+ let ratingKey = url.pathComponents.dropFirst().dropFirst(2).first ?? ""
+
+ lock.lock()
+ let shouldFail = failingRatingKeys.contains(ratingKey)
+ lock.unlock()
+ if shouldFail {
+ throw PlexAPIError.invalidResponse
+ }
+
+ let response = try #require(HTTPURLResponse(
+ url: url,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, PlexRelatedContentTests.relatedHubsData(ratingKey: ratingKey))
+ }
+}
+
+private actor RelatedPageLoadGate {
+ private var hasStarted = false
+ private var startContinuation: CheckedContinuation?
+ private var loadContinuation: CheckedContinuation?
+
+ func suspendLoad() async {
+ hasStarted = true
+ startContinuation?.resume()
+ startContinuation = nil
+ await withCheckedContinuation { continuation in
+ loadContinuation = continuation
+ }
+ }
+
+ func waitUntilLoadStarts() async {
+ guard !hasStarted else {
+ return
+ }
+ await withCheckedContinuation { continuation in
+ startContinuation = continuation
+ }
+ }
+
+ func finishLoad() {
+ loadContinuation?.resume()
+ loadContinuation = nil
+ }
+}
+
+private func makeRelatedContentMockSession(
+ handler: @escaping @Sendable (URLRequest) throws -> (HTTPURLResponse, Data)
+) -> URLSession {
+ RelatedContentMockURLProtocol.requestHandler = handler
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.protocolClasses = [RelatedContentMockURLProtocol.self]
+ return URLSession(configuration: configuration)
+}
+
+private final class RelatedContentMockURLProtocol: URLProtocol, @unchecked Sendable {
+ nonisolated(unsafe) static var requestHandler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))?
+
+ override static func canInit(with request: URLRequest) -> Bool {
+ true
+ }
+
+ override static func canonicalRequest(for request: URLRequest) -> URLRequest {
+ request
+ }
+
+ override func startLoading() {
+ guard let handler = Self.requestHandler else {
+ client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
+ return
+ }
+
+ do {
+ let (response, data) = try handler(request)
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: data)
+ client?.urlProtocolDidFinishLoading(self)
+ } catch {
+ client?.urlProtocol(self, didFailWithError: error)
+ }
+ }
+
+ override func stopLoading() {}
+}
diff --git a/Tests/PlexBarTests/PlexRequestTests.swift b/PlexBarTests/PlexRequestTests.swift
similarity index 53%
rename from Tests/PlexBarTests/PlexRequestTests.swift
rename to PlexBarTests/PlexRequestTests.swift
index 0fc5c64..58df0d5 100644
--- a/Tests/PlexBarTests/PlexRequestTests.swift
+++ b/PlexBarTests/PlexRequestTests.swift
@@ -1,6 +1,10 @@
+import PlexModels
import AppKit
+import CoreGraphics
import Foundation
+import ImageIO
import Testing
+import UniformTypeIdentifiers
@testable import PlexBar
@Suite(.serialized)
@@ -41,43 +45,10 @@ struct PlexRequestTests {
#expect(request.value(forHTTPHeaderField: "X-Plex-Platform") == "macOS")
#expect(request.value(forHTTPHeaderField: "X-Plex-Device") == "Mac")
#expect(request.value(forHTTPHeaderField: "X-Plex-Device-Name") == "Mac (\(AppConstants.appName))")
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Pms-Api-Version") == "1.0.0")
}
- @Test func fetchSessionUsesSessionKeyQueryParameter() async throws {
- let capture = RequestCapture()
- let session = makeMockSession { request in
- capture.record(request)
-
- let response = try #require(HTTPURLResponse(
- url: request.url!,
- statusCode: 200,
- httpVersion: nil,
- headerFields: nil
- ))
- let data = try #require(#"{"MediaContainer":{"Metadata":[]}}"#.data(using: .utf8))
- return (response, data)
- }
-
- let client = PlexAPIClient(session: session)
- let serverURL = try #require(PlexURLBuilder.normalizeServerURL("http://plex.local:32400"))
- let clientContext = PlexClientContext(clientIdentifier: "client-123")
-
- let fetchedSession = try await client.fetchSession(using: PlexConnectionConfiguration(
- serverURL: serverURL,
- token: "server-token",
- clientContext: clientContext
- ), sessionKey: "77")
-
- #expect(fetchedSession == nil)
-
- let request = try #require(capture.request)
- let requestURL = try #require(request.url)
- let components = try #require(URLComponents(url: requestURL, resolvingAgainstBaseURL: false))
- #expect(components.path == "/status/sessions")
- #expect(components.queryItems?.contains(where: { $0.name == "sessionKey" && $0.value == "77" }) == true)
- }
-
- @Test func fetchSessionReturnsOnlyTheMatchingSessionKey() async throws {
+ @Test func fetchSessionsReturnsTheCompleteActiveSessionList() async throws {
let session = makeMockSession { request in
let response = try #require(HTTPURLResponse(
url: request.url!,
@@ -122,14 +93,14 @@ struct PlexRequestTests {
let serverURL = try #require(PlexURLBuilder.normalizeServerURL("http://plex.local:32400"))
let clientContext = PlexClientContext(clientIdentifier: "client-123")
- let fetchedSession = try await client.fetchSession(using: PlexConnectionConfiguration(
+ let fetchedSessions = try await client.fetchSessions(using: PlexConnectionConfiguration(
serverURL: serverURL,
token: "server-token",
clientContext: clientContext
- ), sessionKey: "77")
+ ))
- #expect(fetchedSession?.canonicalSessionKey == "77")
- #expect(fetchedSession?.title == "Right Session")
+ #expect(fetchedSessions.map(\.canonicalSessionKey) == ["44", "77"])
+ #expect(fetchedSessions.map(\.title) == ["Wrong Session", "Right Session"])
}
@Test func fetchStreamLevelsUsesStreamEndpointAndSubsample() async throws {
@@ -227,18 +198,185 @@ struct PlexRequestTests {
let client = PlexAuthClient(session: session)
let clientContext = PlexClientContext(clientIdentifier: "client-123")
+ let identity = try PlexDeviceSigningIdentity.generate(keyID: "key-123")
+ let jwk = try identity.publicJWK(includeUse: false)
- let pin = try await client.createPin(clientContext: clientContext)
+ let pin = try await client.createPin(jwk: jwk, clientContext: clientContext)
#expect(pin.id == 7)
#expect(pin.code == "pin-code")
let request = try #require(capture.request)
#expect(request.httpMethod == "POST")
+ #expect(request.url?.host == "clients.plex.tv")
+ #expect(request.url?.path == "/api/v2/pins")
#expect(request.value(forHTTPHeaderField: "Accept") == "application/json")
+ #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
#expect(request.value(forHTTPHeaderField: "X-Plex-Token") == nil)
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Pms-Api-Version") == nil)
#expect(request.value(forHTTPHeaderField: "X-Plex-Client-Identifier") == "client-123")
#expect(request.value(forHTTPHeaderField: "X-Plex-Version") == AppConstants.productVersion)
+
+ let body = try requestBodyData(request)
+ #expect(!body.isEmpty)
+ let object = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any])
+ #expect(object["strong"] as? Bool == true)
+ let encodedJWK = try #require(object["jwk"] as? [String: Any])
+ #expect(encodedJWK["kty"] as? String == "OKP")
+ #expect(encodedJWK["crv"] as? String == "Ed25519")
+ #expect(encodedJWK["alg"] as? String == "EdDSA")
+ #expect(encodedJWK["kid"] as? String == "key-123")
+ #expect(encodedJWK["x"] as? String == jwk.x)
+ #expect(encodedJWK["use"] == nil)
+ }
+
+ @Test func createPinCanRequestTelevisionLinkCode() async throws {
+ let capture = RequestCapture()
+ let session = makeMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ let data = try #require(#"{"id":8,"code":"ABCD","authToken":null}"#.data(using: .utf8))
+ return (response, data)
+ }
+ let identity = try PlexDeviceSigningIdentity.generate(keyID: "key-tv")
+
+ _ = try await PlexAuthClient(session: session).createPin(
+ jwk: identity.publicJWK(includeUse: false),
+ strong: false,
+ clientContext: PlexClientContext(clientIdentifier: "tv-client")
+ )
+
+ let request = try #require(capture.request)
+ let body = try requestBodyData(request)
+ let object = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any])
+ #expect(object["strong"] as? Bool == false)
+ #expect((object["jwk"] as? [String: Any])?["kid"] as? String == "key-tv")
+ }
+
+ @Test func fetchPinSendsDeviceJWTAsQueryParameter() async throws {
+ let capture = RequestCapture()
+ let session = makeMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ let data = try #require(#"{"id":7,"code":"pin-code","authToken":"account-jwt"}"#.data(using: .utf8))
+ return (response, data)
+ }
+
+ let client = PlexAuthClient(session: session)
+ let pin = try await client.fetchPin(
+ id: "7",
+ deviceJWT: "signed.device.jwt",
+ clientContext: PlexClientContext(clientIdentifier: "client-123")
+ )
+
+ #expect(pin.authToken == "account-jwt")
+ let request = try #require(capture.request)
+ let components = try #require(request.url.flatMap { URLComponents(url: $0, resolvingAgainstBaseURL: false) })
+ #expect(request.httpMethod == "GET")
+ #expect(components.host == "clients.plex.tv")
+ #expect(components.path == "/api/v2/pins/7")
+ #expect(components.queryItems == [URLQueryItem(name: "deviceJWT", value: "signed.device.jwt")])
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Token") == nil)
+ }
+
+ @Test func registerJWKUsesLegacyTokenAndSignatureUse() async throws {
+ let capture = RequestCapture()
+ let session = makeMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 204,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, Data())
+ }
+ let identity = try PlexDeviceSigningIdentity.generate(keyID: "key-123")
+ let client = PlexAuthClient(session: session)
+
+ try await client.registerJWK(
+ identity.publicJWK(includeUse: true),
+ legacyToken: "legacy-token",
+ clientContext: PlexClientContext(clientIdentifier: "client-123")
+ )
+
+ let request = try #require(capture.request)
+ #expect(request.httpMethod == "POST")
+ #expect(request.url == PlexRemoteService.clientsURL(path: "/api/v2/auth/jwk"))
+ #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Token") == "legacy-token")
+ let body = try requestBodyData(request)
+ let object = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any])
+ let jwk = try #require(object["jwk"] as? [String: Any])
+ #expect(jwk["kid"] as? String == "key-123")
+ #expect(jwk["use"] as? String == "sig")
+ }
+
+ @Test func fetchJWTNonceUsesCanonicalEndpointWithoutAccountToken() async throws {
+ let capture = RequestCapture()
+ let session = makeMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ let data = try #require(#"{"nonce":"plex-nonce"}"#.data(using: .utf8))
+ return (response, data)
+ }
+ let client = PlexAuthClient(session: session)
+
+ let nonce = try await client.fetchJWTNonce(
+ clientContext: PlexClientContext(clientIdentifier: "client-123")
+ )
+
+ #expect(nonce == "plex-nonce")
+ let request = try #require(capture.request)
+ #expect(request.httpMethod == "GET")
+ #expect(request.url == PlexRemoteService.clientsURL(path: "/api/v2/auth/nonce"))
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Token") == nil)
+ }
+
+ @Test func exchangeDeviceJWTSendsSignedJWTInJSONBody() async throws {
+ let capture = RequestCapture()
+ let session = makeMockSession { request in
+ capture.record(request)
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ let data = try #require(#"{"auth_token":"account-jwt"}"#.data(using: .utf8))
+ return (response, data)
+ }
+ let client = PlexAuthClient(session: session)
+
+ let token = try await client.exchangeDeviceJWT(
+ "signed.device.jwt",
+ clientContext: PlexClientContext(clientIdentifier: "client-123")
+ )
+
+ #expect(token == "account-jwt")
+ let request = try #require(capture.request)
+ #expect(request.httpMethod == "POST")
+ #expect(request.url == PlexRemoteService.clientsURL(path: "/api/v2/auth/token"))
+ #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Token") == nil)
+ let body = try requestBodyData(request)
+ let object = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any])
+ #expect(object["jwt"] as? String == "signed.device.jwt")
}
@Test func fetchAuthenticatedUserUsesPlexTvUserEndpoint() async throws {
@@ -260,7 +398,16 @@ struct PlexRequestTests {
"title": "Test User",
"email": "test-user@example.com",
"thumb": "\(avatarURL)",
- "friendlyName": ""
+ "friendlyName": "",
+ "subscriptions": {
+ "subscription": [{
+ "type": "plexpass",
+ "state": "active",
+ "mode": "recurring",
+ "active": true,
+ "subscribedAt": "2026-08-01T00:00:00Z"
+ }]
+ }
}
""".data(using: .utf8))
return (response, data)
@@ -278,8 +425,16 @@ struct PlexRequestTests {
title: "Test User",
email: "test-user@example.com",
thumb: avatarURL,
- friendlyName: ""
+ friendlyName: "",
+ subscriptions: [PlexUserSubscription(
+ type: "plexpass",
+ state: "active",
+ mode: "recurring",
+ active: true,
+ subscribedAt: "2026-08-01T00:00:00Z"
+ )]
))
+ #expect(authenticatedUser.hasDownloadsAccountEntitlement)
let request = try #require(capture.request)
#expect(request.httpMethod == "GET")
@@ -289,7 +444,7 @@ struct PlexRequestTests {
#expect(request.value(forHTTPHeaderField: "X-Plex-Client-Identifier") == "client-123")
}
- @Test func fetchServersParsesServerResourcesFromXML() async throws {
+ @Test func fetchServersUsesResourceConnectionsAndLegacyDeviceCredential() async throws {
let capture = RequestCapture()
let session = makeMockSession { request in
capture.record(request)
@@ -300,19 +455,43 @@ struct PlexRequestTests {
httpVersion: nil,
headerFields: nil
))
- let data = try #require(#"""
-
-
-
-
-
-
-
- """#.data(using: .utf8))
+ let data: Data
+ switch request.url?.path {
+ case "/api/v2/resources":
+ data = try #require(#"""
+ [
+ {
+ "name": "Test Server",
+ "clientIdentifier": "server-id",
+ "provides": "server,player",
+ "accessToken": "resource-jwt-that-pms-rejects",
+ "productVersion": "1.2.3-abc",
+ "connections": [
+ { "uri": "https://10-0-0-2.server-id.plex.direct:32400", "local": true, "relay": false },
+ { "uri": "https://203-0-113-10.server-id.plex.direct:32400", "local": false, "relay": false },
+ { "uri": "https://203-0-113-20.server-id.plex.direct:8443", "local": false, "relay": true }
+ ]
+ }
+ ]
+ """#.data(using: .utf8))
+ case "/api/v2/devices":
+ data = try #require(#"""
+ [
+ {
+ "name": "Test Server",
+ "clientIdentifier": "server-id",
+ "provides": "server",
+ "token": "legacy-pms-token",
+ "connections": [
+ { "uri": "https://10-0-0-2.server-id.plex.direct:32400" }
+ ]
+ }
+ ]
+ """#.data(using: .utf8))
+ default:
+ Issue.record("Unexpected request: \(request)")
+ throw URLError(.unsupportedURL)
+ }
return (response, data)
}
@@ -325,21 +504,25 @@ struct PlexRequestTests {
#expect(servers.count == 1)
#expect(servers.first?.id == "server-id")
#expect(servers.first?.name == "Test Server")
- #expect(servers.first?.accessToken == "server-token")
+ #expect(servers.first?.accessToken == "legacy-pms-token")
#expect(servers.first?.connections.count == 3)
#expect(servers.first?.connections.map(\.kind) == [.local, .remote, .relay])
- let request = try #require(capture.request)
- #expect(request.url == PlexRemoteService.apiURL(
- path: "/api/resources",
- queryItems: [
- URLQueryItem(name: "includeHttps", value: "1"),
- URLQueryItem(name: "includeRelay", value: "1"),
- URLQueryItem(name: "includeIPv6", value: "1"),
- ]
- ))
- #expect(request.value(forHTTPHeaderField: "Accept") == "application/xml")
- #expect(request.value(forHTTPHeaderField: "X-Plex-Token") == "user-token")
+ #expect(capture.requests.map(\.url) == [
+ PlexRemoteService.clientsURL(
+ path: "/api/v2/resources",
+ queryItems: [
+ URLQueryItem(name: "includeHttps", value: "1"),
+ URLQueryItem(name: "includeRelay", value: "1"),
+ URLQueryItem(name: "includeIPv6", value: "1"),
+ ]
+ ),
+ PlexRemoteService.clientsURL(path: "/api/v2/devices")
+ ])
+ for request in capture.requests {
+ #expect(request.value(forHTTPHeaderField: "Accept") == "application/json")
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Token") == "user-token")
+ }
}
@Test func fetchGeoLocationUsesPlexTvGeoIPEndpoint() async throws {
@@ -386,6 +569,36 @@ struct PlexRequestTests {
#expect(request.value(forHTTPHeaderField: "X-Plex-Token") == "user-token")
}
+ @Test(arguments: [false, true])
+ func imageClientRejectsMissingAndInvalidLocalFiles(fileExists: Bool) async throws {
+ let url = FileManager.default.temporaryDirectory.appending(path: "plex-image-\(UUID().uuidString).png")
+ defer { try? FileManager.default.removeItem(at: url) }
+ if fileExists { try Data("not an image".utf8).write(to: url) }
+ let session = makeMockSession { _ in
+ Issue.record("Local image reads must not use the HTTP transport")
+ throw URLError(.unsupportedURL)
+ }
+ let client = PlexImageClient(session: session, cache: PlexImageMemoryCache(), requestCoordinator: PlexImageRequestCoordinator())
+
+ #expect(await client.fetchCGImageResult(
+ from: [url], token: "", clientContext: PlexClientContext(clientIdentifier: "tests")
+ ) == nil)
+ }
+
+ @Test func imageClientRejectsHTTPFailureWithValidImageBody() async throws {
+ let imageData = try #require(makeArtworkData(width: 40, height: 40))
+ let session = makeMockSession { request in
+ let response = try #require(HTTPURLResponse(url: request.url!, statusCode: 403, httpVersion: nil, headerFields: nil))
+ return (response, imageData)
+ }
+ let client = PlexImageClient(session: session, cache: PlexImageMemoryCache(), requestCoordinator: PlexImageRequestCoordinator())
+ let url = try #require(URL(string: "https://plex.local/forbidden-image"))
+
+ #expect(await client.fetchCGImageResult(
+ from: [url], token: "", clientContext: PlexClientContext(clientIdentifier: "tests")
+ ) == nil)
+ }
+
@Test func imageClientUsesHeaderTokenInsteadOfQueryToken() async throws {
let capture = RequestCapture()
let imageData = try #require(NSImage(
@@ -428,6 +641,45 @@ struct PlexRequestTests {
#expect(request.value(forHTTPHeaderField: "X-Plex-Client-Identifier") == "client-123")
}
+ @Test func publicImageRequestsDoNotDisclosePlexDeviceHeaders() async throws {
+ let capture = RequestCapture()
+ let imageData = try #require(NSImage(
+ systemSymbolName: "person.circle.fill",
+ accessibilityDescription: nil
+ )?.tiffRepresentation)
+ let session = makeMockSession { request in
+ capture.record(request)
+
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, imageData)
+ }
+ let client = PlexImageClient(
+ session: session,
+ cache: PlexImageMemoryCache(),
+ requestCoordinator: PlexImageRequestCoordinator()
+ )
+ let imageURL = try #require(URL(string: "https://metadata-static.plex.tv/person.jpg"))
+
+ let image = await client.fetchImage(
+ from: [imageURL],
+ token: "",
+ clientContext: PlexClientContext(clientIdentifier: "stable-device-identifier")
+ )
+
+ #expect(image != nil)
+ let request = try #require(capture.request)
+ #expect(request.value(forHTTPHeaderField: "Accept") == "image/*")
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Token") == nil)
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Client-Identifier") == nil)
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Product") == nil)
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Platform") == nil)
+ }
+
@Test func imageClientReturnsCachedImageWithoutRepeatingNetworkRequest() async throws {
let capture = RequestCapture()
let cache = PlexImageMemoryCache.shared
@@ -474,6 +726,137 @@ struct PlexRequestTests {
#expect(requestCounter.value == 1)
}
+ @Test func imageClientCoalescesConcurrentAuthenticatedArtworkRequests() async throws {
+ let requestCounter = RequestCounter()
+ let imageData = try #require(makeArtworkData(width: 320, height: 480))
+ let session = makeMockSession { request in
+ requestCounter.increment()
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, imageData)
+ }
+ let client = PlexImageClient(
+ session: session,
+ cache: PlexImageMemoryCache(),
+ requestCoordinator: PlexImageRequestCoordinator()
+ )
+ let clientContext = PlexClientContext(clientIdentifier: "coalescing-client")
+ let imageURL = try #require(URL(string: "https://plex.local/library/metadata/coalesced/thumb"))
+
+ async let firstImage = client.fetchCGImageResult(
+ from: [imageURL],
+ token: "server-token",
+ clientContext: clientContext,
+ maximumPixelSize: 240
+ )
+ async let secondImage = client.fetchCGImageResult(
+ from: [imageURL],
+ token: "server-token",
+ clientContext: clientContext,
+ maximumPixelSize: 240
+ )
+ let images = await (firstImage, secondImage)
+
+ #expect(images.0 != nil)
+ #expect(images.1 != nil)
+ #expect(requestCounter.value == 1)
+ }
+
+ @Test func imageClientDownsamplesAndSeparatesCacheEntriesByPixelSize() async throws {
+ let requestCounter = RequestCounter()
+ let imageData = try #require(makeArtworkData(width: 400, height: 200))
+ let session = makeMockSession { request in
+ requestCounter.increment()
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, imageData)
+ }
+ let client = PlexImageClient(
+ session: session,
+ cache: PlexImageMemoryCache(),
+ requestCoordinator: PlexImageRequestCoordinator()
+ )
+ let clientContext = PlexClientContext(clientIdentifier: "downsample-client")
+ let imageURL = try #require(URL(string: "https://plex.local/library/metadata/sized/thumb"))
+
+ let small = await client.fetchCGImageResult(
+ from: [imageURL],
+ token: "server-token",
+ clientContext: clientContext,
+ maximumPixelSize: 80
+ )
+ let large = await client.fetchCGImageResult(
+ from: [imageURL],
+ token: "server-token",
+ clientContext: clientContext,
+ maximumPixelSize: 160
+ )
+ let cachedSmall = await client.fetchCGImageResult(
+ from: [imageURL],
+ token: "server-token",
+ clientContext: clientContext,
+ maximumPixelSize: 80
+ )
+
+ #expect(small?.image.width == 80)
+ #expect(small?.image.height == 40)
+ #expect(large?.image.width == 160)
+ #expect(large?.image.height == 80)
+ #expect(cachedSmall?.image.width == 80)
+ #expect(requestCounter.value == 2)
+ }
+
+ @Test func artworkPrefetcherDeduplicatesWorkWithVisibleArtworkLoading() async throws {
+ let requestCounter = RequestCounter()
+ let imageData = try #require(makeArtworkData(width: 240, height: 360))
+ let session = makeMockSession { request in
+ requestCounter.increment()
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ return (response, imageData)
+ }
+ let client = PlexImageClient(
+ session: session,
+ cache: PlexImageMemoryCache(),
+ requestCoordinator: PlexImageRequestCoordinator()
+ )
+ let prefetcher = PlexArtworkPrefetcher(
+ imageClient: client,
+ maximumConcurrentRequests: 2,
+ maximumQueuedRequests: 4
+ )
+ let imageURL = try #require(URL(string: "https://plex.local/library/metadata/prefetched/thumb"))
+ let request = PlexArtworkPrefetchRequest(
+ candidateURLs: [imageURL],
+ token: "server-token",
+ clientContext: PlexClientContext(clientIdentifier: "prefetch-client"),
+ maximumPixelSize: 180
+ )
+
+ await prefetcher.prefetch([request, request])
+ let visibleImage = await client.fetchCGImageResult(
+ from: request.candidateURLs,
+ token: request.token,
+ clientContext: request.clientContext,
+ maximumPixelSize: request.maximumPixelSize
+ )
+
+ #expect(visibleImage != nil)
+ #expect(requestCounter.value == 1)
+ }
+
@Test func fetchHistoryUsesThirtyDayCutoffAndPaginationHeaders() async throws {
let capture = RequestCapture()
let session = makeMockSession { request in
@@ -514,7 +897,48 @@ struct PlexRequestTests {
#expect(request.value(forHTTPHeaderField: "X-Plex-Token") == "server-token")
}
- @Test func fetchAccountsUsesStatisticsMediaEndpoint() async throws {
+ @Test func fetchHistoryCanUsePlexMetadataHierarchyScoping() async throws {
+ let capture = RequestCapture()
+ let session = makeMockSession { request in
+ capture.record(request)
+
+ let response = try #require(HTTPURLResponse(
+ url: request.url!,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ let data = try #require(#"{"MediaContainer":{"Metadata":[]}}"#.data(using: .utf8))
+ return (response, data)
+ }
+
+ let client = PlexAPIClient(session: session)
+ let serverURL = try #require(PlexURLBuilder.normalizeServerURL("http://plex.local:32400"))
+
+ _ = try await client.fetchHistory(
+ using: PlexConnectionConfiguration(
+ serverURL: serverURL,
+ token: "server-token",
+ clientContext: PlexClientContext(clientIdentifier: "client-123")
+ ),
+ since: Date(timeIntervalSince1970: 1_700_000_000),
+ metadataItemID: 42,
+ pageSize: 50
+ )
+
+ let request = try #require(capture.request)
+ let requestURL = try #require(request.url)
+ let components = try #require(URLComponents(url: requestURL, resolvingAgainstBaseURL: false))
+ #expect(components.queryItems?.contains {
+ $0.name == "metadataItemID" && $0.value == "42"
+ } == true)
+ #expect(components.queryItems?.contains {
+ $0.name == "viewedAt>" && $0.value == "1700000000"
+ } == true)
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Container-Size") == "50")
+ }
+
+ @Test func fetchHistoryIdentityDirectoryUsesStatisticsMediaEndpoint() async throws {
let capture = RequestCapture()
let avatarURL = PlexRemoteService.apiBaseURL.absoluteString + "/users/avatar"
let session = makeMockSession { request in
@@ -527,7 +951,7 @@ struct PlexRequestTests {
headerFields: nil
))
let data = try #require("""
- {"MediaContainer":{"Account":[{"id":7,"name":"test-user","thumb":"\(avatarURL)"}]}}
+ {"MediaContainer":{"Account":[{"id":7,"name":"test-user","thumb":"\(avatarURL)"}],"Device":[{"id":12,"name":"Living Room","platform":"tvOS"}]}}
""".data(using: .utf8))
return (response, data)
}
@@ -536,13 +960,14 @@ struct PlexRequestTests {
let serverURL = try #require(PlexURLBuilder.normalizeServerURL("http://plex.local:32400"))
let clientContext = PlexClientContext(clientIdentifier: "client-123")
- let accounts = try await client.fetchAccounts(using: PlexConnectionConfiguration(
+ let directory = try await client.fetchHistoryIdentityDirectory(using: PlexConnectionConfiguration(
serverURL: serverURL,
token: "server-token",
clientContext: clientContext
))
- #expect(accounts == [PlexAccount(id: 7, name: "test-user", thumb: avatarURL)])
+ #expect(directory.accounts == [PlexAccount(id: 7, name: "test-user", thumb: avatarURL)])
+ #expect(directory.devices == [PlexHistoryDevice(id: 12, name: "Living Room", platform: "tvOS")])
let request = try #require(capture.request)
#expect(request.url?.path == "/statistics/media")
@@ -671,3 +1096,39 @@ private final class RequestCounter: @unchecked Sendable {
lock.unlock()
}
}
+
+private func makeArtworkData(width: Int, height: Int) -> Data? {
+ let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
+ guard let context = CGContext(
+ data: nil,
+ width: width,
+ height: height,
+ bitsPerComponent: 8,
+ bytesPerRow: width * 4,
+ space: colorSpace,
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ ) else {
+ return nil
+ }
+
+ context.setFillColor(red: 0.18, green: 0.42, blue: 0.76, alpha: 1)
+ context.fill(CGRect(x: 0, y: 0, width: width, height: height))
+ guard let image = context.makeImage() else {
+ return nil
+ }
+
+ let data = NSMutableData()
+ guard let destination = CGImageDestinationCreateWithData(
+ data,
+ UTType.png.identifier as CFString,
+ 1,
+ nil
+ ) else {
+ return nil
+ }
+ CGImageDestinationAddImage(destination, image, nil)
+ guard CGImageDestinationFinalize(destination) else {
+ return nil
+ }
+ return data as Data
+}
diff --git a/PlexBarTests/PlexRewindOnResumeTests.swift b/PlexBarTests/PlexRewindOnResumeTests.swift
new file mode 100644
index 0000000..d78061f
--- /dev/null
+++ b/PlexBarTests/PlexRewindOnResumeTests.swift
@@ -0,0 +1,98 @@
+import Foundation
+import Testing
+@testable import PlexBar
+
+@Suite
+struct PlexRewindOnResumeTests {
+ @Test func settingCoversNoneThroughThirtySecondsAndFormatsNativeValues() {
+ #expect(PlexRewindOnResume(seconds: -1) == .none)
+ #expect(PlexRewindOnResume(seconds: 0).label == "None")
+ #expect(PlexRewindOnResume(seconds: 1).label == "1 Second")
+ #expect(PlexRewindOnResume(seconds: 12).label == "12 Seconds")
+ #expect(PlexRewindOnResume(seconds: 31).seconds == 30)
+ }
+
+ @Test func targetRewindsAndClampsAtTheBeginning() {
+ let preference = PlexRewindOnResume(seconds: 10)
+
+ #expect(preference.target(from: 42) == 32)
+ #expect(preference.target(from: 4) == 0)
+ #expect(preference.target(from: 0) == nil)
+ #expect(preference.target(from: .nan) == nil)
+ #expect(PlexRewindOnResume.none.target(from: 42) == nil)
+ }
+
+ @Test func policyAppliesOnlyToAnInSessionPausedResume() {
+ let preference = PlexRewindOnResume(seconds: 10)
+
+ #expect(PlexRewindOnResumePolicy.action(
+ status: .paused,
+ position: 42,
+ preference: preference
+ ) == .seekThenPlay(target: 32))
+ #expect(PlexRewindOnResumePolicy.action(
+ status: .paused,
+ position: 42,
+ preference: .none
+ ) == .playImmediately)
+
+ for status in [
+ PlexPlaybackStatus.idle,
+ .preparing,
+ .playing,
+ .buffering,
+ .ended,
+ .failed("Unavailable"),
+ ] {
+ #expect(PlexRewindOnResumePolicy.action(
+ status: status,
+ position: 42,
+ preference: preference
+ ) == nil)
+ }
+ }
+
+ @Test func pendingRewindRemainsPauseableUntilItsSeekCompletes() {
+ #expect(PlexRewindOnResumePolicy.transportAction(
+ status: .paused,
+ hasPendingRewind: true
+ ) == .pause)
+ #expect(PlexRewindOnResumePolicy.transportAction(
+ status: .paused,
+ hasPendingRewind: false
+ ) == .play)
+ }
+
+ @Test func nativePlayPauseRemainsOwnedByAVKitUnlessPlexMustIntervene() {
+ #expect(!PlexRewindOnResumePolicy.shouldInterceptNativePlayPausePress(
+ status: .playing,
+ hasPendingRewind: false,
+ isPlaybackControlBusy: false,
+ preference: PlexRewindOnResume(seconds: 10)
+ ))
+ #expect(!PlexRewindOnResumePolicy.shouldInterceptNativePlayPausePress(
+ status: .paused,
+ hasPendingRewind: false,
+ isPlaybackControlBusy: false,
+ preference: .none
+ ))
+ #expect(PlexRewindOnResumePolicy.shouldInterceptNativePlayPausePress(
+ status: .paused,
+ hasPendingRewind: false,
+ isPlaybackControlBusy: false,
+ preference: PlexRewindOnResume(seconds: 10)
+ ))
+ #expect(PlexRewindOnResumePolicy.shouldInterceptNativePlayPausePress(
+ status: .playing,
+ hasPendingRewind: true,
+ isPlaybackControlBusy: false,
+ preference: .none
+ ))
+ #expect(PlexRewindOnResumePolicy.shouldInterceptNativePlayPausePress(
+ status: .paused,
+ hasPendingRewind: false,
+ isPlaybackControlBusy: true,
+ preference: .none
+ ))
+ }
+}
diff --git a/Tests/PlexBarTests/PlexServerPreviewStoreTests.swift b/PlexBarTests/PlexServerPreviewStoreTests.swift
similarity index 99%
rename from Tests/PlexBarTests/PlexServerPreviewStoreTests.swift
rename to PlexBarTests/PlexServerPreviewStoreTests.swift
index 821ec52..1971040 100644
--- a/Tests/PlexBarTests/PlexServerPreviewStoreTests.swift
+++ b/PlexBarTests/PlexServerPreviewStoreTests.swift
@@ -1,3 +1,4 @@
+import PlexModels
import Foundation
import Testing
@testable import PlexBar
diff --git a/Tests/PlexBarTests/PlexSessionNotificationTests.swift b/PlexBarTests/PlexSessionNotificationTests.swift
similarity index 62%
rename from Tests/PlexBarTests/PlexSessionNotificationTests.swift
rename to PlexBarTests/PlexSessionNotificationTests.swift
index 7ae6b5b..99e4721 100644
--- a/Tests/PlexBarTests/PlexSessionNotificationTests.swift
+++ b/PlexBarTests/PlexSessionNotificationTests.swift
@@ -1,3 +1,4 @@
+import PlexModels
import Foundation
import Testing
@testable import PlexBar
@@ -14,9 +15,7 @@ import Testing
"viewOffset": 1234,
"ratingKey": "900",
"key": "/library/metadata/900",
- "transcodeSession": {
- "key": "/transcode/sessions/abc"
- }
+ "transcodeSession": "abc"
}
]
}
@@ -37,6 +36,60 @@ import Testing
])
}
+@Test(arguments: ["null", "\"\"", "\" \""])
+func emptyTranscodeReferenceClearsThePreviousTranscode(value: String) throws {
+ let data = Data("""
+ {"NotificationContainer":{"type":"playing","PlaySessionStateNotification":[
+ {"sessionKey":"44","state":"playing","transcodeSession":\(value)}
+ ]}}
+ """.utf8)
+
+ let event = try #require(PlexSessionEventsClient.decodeEvents(from: data).first)
+ guard case .playing(let notification) = event else {
+ Issue.record("Expected a playback notification")
+ return
+ }
+ #expect(notification.hasTranscodeSession)
+ #expect(notification.transcodeSessionKey == nil)
+}
+
+@Test func missingTranscodeReferenceDoesNotClearThePreviousTranscode() throws {
+ let data = Data(#"{"NotificationContainer":{"type":"playing","PlaySessionStateNotification":[{"sessionKey":"44","state":"playing"}]}}"#.utf8)
+ let event = try #require(PlexSessionEventsClient.decodeEvents(from: data).first)
+ guard case .playing(let notification) = event else {
+ Issue.record("Expected a playback notification")
+ return
+ }
+ #expect(notification.hasTranscodeSession == false)
+ #expect(notification.transcodeSessionKey == nil)
+}
+
+@Test func objectValuedTranscodeReferenceReportsTheFailingField() throws {
+ let data = Data(#"{"NotificationContainer":{"type":"playing","PlaySessionStateNotification":[{"sessionKey":"44","state":"stopped","transcodeSession":{"key":"private-value"}}]}}"#.utf8)
+ do {
+ _ = try PlexSessionEventsClient.decodeEvents(from: data)
+ Issue.record("Expected the invalid transcode field to fail decoding")
+ } catch let error as DecodingError {
+ let summary = PlexSessionEventsClient.decodingFailureSummary(error)
+ #expect(summary.contains("type mismatch"))
+ #expect(summary.contains("transcodeSession"))
+ #expect(summary.contains("private-value") == false)
+ }
+}
+
+@Test func decodingFailureSummaryExcludesPrivateErrorDetails() {
+ let error = DecodingError.dataCorrupted(.init(
+ codingPath: [],
+ debugDescription: "Invalid credential: private-value"
+ ))
+ #expect(PlexSessionEventsClient.decodingFailureSummary(error) == "invalid data at root")
+}
+
+@Test func unrelatedServerNotificationsProduceNoSessionEvents() throws {
+ let data = Data(#"{"NotificationContainer":{"type":"timeline","TimelineEntry":[{"state":5,"itemID":123}]}}"#.utf8)
+ #expect(try PlexSessionEventsClient.decodeEvents(from: data).isEmpty)
+}
+
@Test func decodesTranscodeSessionUpdateEvent() async throws {
let data = try #require(#"""
{
diff --git a/PlexBarTests/PlexSessionPlaybackDetailsTests.swift b/PlexBarTests/PlexSessionPlaybackDetailsTests.swift
new file mode 100644
index 0000000..ae72ecd
--- /dev/null
+++ b/PlexBarTests/PlexSessionPlaybackDetailsTests.swift
@@ -0,0 +1,150 @@
+import PlexModels
+import Foundation
+import Testing
+@testable import PlexBar
+
+struct PlexSessionPlaybackDetailsTests {
+ @Test func directPlayUsesSelectedTracksAndSessionBandwidth() throws {
+ let details = try details(#"""
+ {"title":"Example","Player":{},"Session":{"bandwidth":13900},"Media":[{"Part":[{
+ "decision":"directplay","Stream":[
+ {"streamType":1,"displayTitle":"1080p (H.264)"},
+ {"streamType":2,"selected":false,"displayTitle":"French (AAC)"},
+ {"streamType":2,"selected":true,"displayTitle":"English (AC3 5.1)"},
+ {"streamType":3,"selected":false,"codec":"srt"}
+ ]
+ }]}]}
+ """#)
+ #expect(details.method == "Direct Play")
+ #expect(details.bandwidth != nil)
+ #expect(details.rows.map(\.source) == ["1080p (H.264)", "English (AC3 5.1)", "None"])
+ #expect(details.rows[1].output == nil)
+ #expect(!details.usesHardware)
+ }
+
+ @Test(arguments: [
+ (#""transcodeHwRequested":true"#, false),
+ (#""transcodeHwEncoding":"""#, false),
+ (#""transcodeHwEncoding":"none""#, false),
+ (#""transcodeHwEncoding":"videotoolbox""#, true),
+ (#""transcodeHwDecoding":"nvdec""#, true)
+ ])
+ func hardwareRequiresAnActiveEngine(fields: String, expected: Bool) throws {
+ let details = try details("""
+ {"title":"Example","Player":{},"live":true,"TranscodeSession":{
+ "videoDecision":"transcode","videoCodec":"h264","sourceVideoCodec":"hevc",\(fields)
+ }}
+ """)
+ #expect(details.usesHardware == expected)
+ #expect(details.rows[0].output == (expected ? "H.264 · HW" : "H.264"))
+ }
+
+ @Test func subtitleConversionDoesNotMarkCopiedVideoAsHardwareTranscoding() throws {
+ let details = try details(#"""
+ {"title":"Example","Player":{},"Media":[{"Part":[{"decision":"transcode","Stream":[
+ {"streamType":1,"decision":"copy","codec":"h264"},
+ {"streamType":3,"selected":true,"decision":"transcode","codec":"ass","language":"English"}
+ ]}]}],"TranscodeSession":{"videoDecision":"copy","transcodeHwEncoding":"videotoolbox"}}
+ """#)
+ #expect(details.method == "Direct Stream")
+ #expect(!details.usesHardware)
+ #expect(details.rows.last?.source == "English")
+ #expect(details.rows.last?.output == "ASS")
+ }
+
+ @Test func missingBandwidthAndAmbiguousAudioAreNotInvented() throws {
+ let details = try details(#"""
+ {"title":"Example","Player":{},"Session":{"bandwidth":-1},"Media":[{"Part":[{
+ "decision":"directplay","Stream":[
+ {"streamType":2,"codec":"aac"},{"streamType":2,"codec":"ac3"}
+ ]
+ }]}]}
+ """#)
+ #expect(details.bandwidth == nil)
+ #expect(details.rows.first?.source == "Unavailable")
+ }
+
+ @Test func mockHTTPPreservesTechnicalFields() async throws {
+ let client = PlexAPIClient(session: PlexDebugMockServer.makeSession())
+ let sessions = try await client.fetchSessions(using: PlexConnectionConfiguration(
+ serverURL: PlexDebugMockServer.mockResolvedConnection.url,
+ token: "plexbar-debug-mock-server-token",
+ clientContext: PlexClientContext(clientIdentifier: "details-tests")
+ ))
+ let transcoding = try #require(sessions.first { $0.deliveryMethod == .transcoding })
+ let details = PlexSessionPlaybackDetails(session: transcoding)
+ #expect(details.usesHardware)
+ #expect(details.rows[0].source == "1080p (HEVC Main 10)")
+ #expect(details.rows[0].output == "H.264 · 8 Mbps · HW")
+ #expect(details.rows[2].source == "English (SRT)")
+ }
+
+ @Test func omittedDirectPlayDecisionMatchesTheSummary() throws {
+ let details = try details(#"""
+ {"title":"Example","Player":{},"Media":[{"Part":[{
+ "Stream":[{"streamType":1,"codec":"h264"}]
+ }]}]}
+ """#)
+ #expect(details.method == "Direct Play")
+ #expect(details.rows[0].output == nil)
+ }
+
+ @Test func absentTechnicalDataRemainsUnavailable() throws {
+ let details = try details(#"{"title":"Example","Player":{}}"#)
+ #expect(details.rows.map(\.source) == ["Unavailable", "Unavailable"])
+ #expect(details.rows.allSatisfy { $0.output == nil })
+ }
+
+ @Test func outputTrackBitratesAreDistinctFromSessionBandwidth() throws {
+ // Shape of the live Plex response: displayTitle describes the source,
+ // while codec and bitrate describe the selected output track.
+ let details = try details(#"""
+ {"title":"Example","Player":{},"Session":{"bandwidth":21300},"Media":[{"Part":[{
+ "decision":"transcode","Stream":[
+ {"streamType":1,"decision":"transcode","codec":"h264","bitrate":20000,"displayTitle":"1080p (HEVC Main 10)"},
+ {"streamType":2,"selected":true,"decision":"transcode","codec":"aac","bitrate":"256","displayTitle":"English (EAC3 5.1)"},
+ {"streamType":3,"selected":true,"decision":"burn","codec":"ass","displayTitle":"English Forced (ASS)"}
+ ]
+ }]}]}
+ """#)
+ #expect(details.rows[0].source == "1080p (HEVC Main 10)")
+ #expect(details.rows[0].output == "H.264 · 20 Mbps")
+ #expect(details.rows[1].source == "English (EAC3 5.1)")
+ #expect(details.rows[1].output == "AAC · 256 Kbps")
+ #expect(details.rows[2].source == "English Forced (ASS)")
+ #expect(details.rows[2].output == "Burn In")
+ }
+
+ @Test(arguments: ["null", "0", "-1"])
+ func unavailableTrackBitrateDoesNotUseSessionBandwidth(bitrate: String) throws {
+ let details = try details("""
+ {"title":"Example","Player":{},"Session":{"bandwidth":21300},"Media":[{"Part":[{
+ "decision":"transcode","Stream":[
+ {"streamType":2,"selected":true,"decision":"transcode","codec":"aac","bitrate":\(bitrate)}
+ ]
+ }]}]}
+ """)
+ #expect(details.rows[0].source == "Unavailable")
+ #expect(details.rows[0].output == "AAC")
+ }
+
+ @Test func copiedTracksAndUnchangedSubtitlesHaveDistinctPresentation() throws {
+ let details = try details(#"""
+ {"title":"Example","Player":{},"Media":[{"Part":[{"decision":"transcode","Stream":[
+ {"streamType":1,"decision":"copy","codec":"h264","bitrate":3775},
+ {"streamType":3,"selected":true,"decision":"copy","codec":"srt","displayTitle":"English (SRT)"}
+ ]}]}]}
+ """#)
+ #expect(details.rows[0].output == "Direct Stream · 3.8 Mbps")
+ #expect(details.rows[1].output == nil)
+ }
+
+ @Test func trackBitrateFormattingRespectsLocale() {
+ #expect(PlexSessionPlaybackDetails.trackBitrateText(kbps: 3775, locale: Locale(identifier: "de_DE")) == "3,8 Mbps")
+ #expect(PlexSessionPlaybackDetails.trackBitrateText(kbps: 256, locale: Locale(identifier: "en_US")) == "256 Kbps")
+ }
+
+ private func details(_ json: String) throws -> PlexSessionPlaybackDetails {
+ PlexSessionPlaybackDetails(session: try JSONDecoder().decode(PlexSession.self, from: Data(json.utf8)))
+ }
+}
diff --git a/Tests/PlexBarTests/PlexSessionStoreTests.swift b/PlexBarTests/PlexSessionStoreTests.swift
similarity index 74%
rename from Tests/PlexBarTests/PlexSessionStoreTests.swift
rename to PlexBarTests/PlexSessionStoreTests.swift
index ab09785..a7abb79 100644
--- a/Tests/PlexBarTests/PlexSessionStoreTests.swift
+++ b/PlexBarTests/PlexSessionStoreTests.swift
@@ -1,3 +1,5 @@
+import PlexModels
+import AppKit
import Foundation
import Testing
@testable import PlexBar
@@ -43,6 +45,215 @@ struct PlexSessionStoreTests {
#expect(fullHydrateCounter.value == 1)
}
+@MainActor
+@Test func activityPollingSharesVisibilityAndStopsWhenHidden() async throws {
+ let fixture = try ActivityRefreshFixture()
+ defer { fixture.stop() }
+ await fixture.ready()
+ let first = UUID(), second = UUID()
+ fixture.store.setActivityVisible(true, consumer: first)
+ fixture.store.setActivityVisible(true, consumer: second)
+ await waitUntil { fixture.clock.pendingCount == 1 }
+ #expect(fixture.requests.value == 1)
+ fixture.clock.advance(by: .seconds(9))
+ #expect(fixture.requests.value == 1)
+ fixture.clock.advance(by: .seconds(1))
+ await waitUntil { fixture.requests.value == 2 && fixture.clock.pendingCount == 1 }
+ #expect(fixture.requests.value == 2)
+ fixture.store.setActivityVisible(false, consumer: first)
+ fixture.clock.advance(by: .seconds(10))
+ await waitUntil { fixture.requests.value == 3 && fixture.clock.pendingCount == 1 }
+ #expect(fixture.requests.value == 3)
+ fixture.store.setActivityVisible(false, consumer: second)
+ await waitUntil { fixture.clock.pendingCount == 0 }
+ #expect(fixture.clock.pendingCount == 0)
+ fixture.clock.advance(by: .seconds(100))
+ #expect(fixture.requests.value == 3)
+ fixture.store.setActivityVisible(true, consumer: first)
+ await waitUntil { fixture.requests.value == 4 && fixture.clock.pendingCount == 1 }
+ #expect(fixture.requests.value == 4)
+}
+
+@MainActor
+@Test(arguments: [true, false])
+func activityPollingRefreshesUnchangedPausedAndEmptySnapshots(empty: Bool) async throws {
+ let fixture = try ActivityRefreshFixture(empty: empty)
+ defer { fixture.stop() }
+ await fixture.ready()
+ let original = try #require(fixture.store.lastHydratedAt)
+ fixture.store.setActivityVisible(true, consumer: UUID())
+ await waitUntil { fixture.clock.pendingCount == 1 }
+ fixture.clock.advance(by: .seconds(10))
+ await waitForSessionStore(fixture.store) { $0.lastHydratedAt != original }
+ #expect(try #require(fixture.store.lastHydratedAt) > original)
+ #expect(fixture.store.activeStreamCount == (empty ? 0 : 1))
+ #expect(fixture.store.activitySummary.totalBandwidthKbps == (empty ? 0 : 8000))
+ #expect(!fixture.store.isLoading)
+}
+
+@MainActor
+@Test func activityPollingPreservesFailedSnapshotAndRecovers() async throws {
+ let fixture = try ActivityRefreshFixture()
+ defer { fixture.stop() }
+ await fixture.ready()
+ let original = try #require(fixture.store.lastHydratedAt)
+ fixture.store.setActivityVisible(true, consumer: UUID())
+ await waitUntil { fixture.clock.pendingCount == 1 }
+ fixture.invalidResponse.withValue { $0 = true }
+ fixture.clock.advance(by: .seconds(10))
+ await waitUntil { fixture.store.activityErrorMessage != nil && fixture.clock.pendingCount == 1 }
+ #expect(fixture.store.activityErrorMessage != nil)
+ #expect(fixture.store.lastHydratedAt == original)
+ #expect(fixture.store.activitySummary.totalBandwidthKbps == 8000)
+ #expect(!fixture.store.isLoading)
+ fixture.invalidResponse.withValue { $0 = false }
+ fixture.bandwidth.withValue { $0 = 12000 }
+ fixture.clock.advance(by: .seconds(10))
+ await waitForSessionStore(fixture.store) { $0.activitySummary.totalBandwidthKbps == 12000 }
+ #expect(fixture.store.activitySummary.totalBandwidthKbps == 12000)
+ #expect(fixture.store.activityErrorMessage == nil)
+ #expect(try #require(fixture.store.lastHydratedAt) > original)
+ #expect(fixture.requests.value == 3)
+}
+
+@MainActor
+@Test func manualRefreshJoinsActivityPollInFlight() async throws {
+ let gate = DispatchSemaphore(value: 0)
+ let fixture = try ActivityRefreshFixture(beforeResponse: { count in
+ if count == 2 { #expect(gate.wait(timeout: .now() + 5) == .success) }
+ })
+ defer { gate.signal(); fixture.stop() }
+ await fixture.ready()
+ fixture.store.setActivityVisible(true, consumer: UUID())
+ await waitUntil { fixture.clock.pendingCount == 1 }
+ fixture.clock.advance(by: .seconds(10))
+ await waitUntil { fixture.requests.value == 2 }
+ #expect(!fixture.store.isLoading)
+ let manual = fixture.store.refreshNow()
+ await waitForSessionStore(fixture.store) { $0.isLoading }
+ #expect(fixture.store.isLoading)
+ gate.signal()
+ await manual.value
+ #expect(fixture.requests.value == 2)
+ #expect(!fixture.store.isLoading)
+}
+
+@MainActor
+@Test(arguments: ["stopped", "paused"])
+func activitySnapshotPreservesNewerPlaybackEvents(state: String) async throws {
+ let gate = DispatchSemaphore(value: 0)
+ let fixture = try ActivityRefreshFixture(beforeResponse: { count in
+ if count == 2 { #expect(gate.wait(timeout: .now() + 5) == .success) }
+ })
+ defer { gate.signal(); fixture.stop() }
+ await fixture.ready()
+ let original = try #require(fixture.store.lastHydratedAt)
+ fixture.store.setActivityVisible(true, consumer: UUID())
+ await waitUntil { fixture.clock.pendingCount == 1 }
+ fixture.clock.advance(by: .seconds(10))
+ await waitUntil { fixture.requests.value == 2 }
+ let notification = try JSONDecoder().decode(PlexPlaySessionStateNotification.self, from: Data("""
+ {"sessionKey":"44","state":"\(state)","viewOffset":9000}
+ """.utf8))
+ let handler = try #require(fixture.handler.value)
+ try await handler(.playing(notification))
+ gate.signal()
+ await waitForSessionStore(fixture.store) { $0.lastHydratedAt != original }
+ #expect(fixture.store.lastHydratedAt != original)
+ if state == "stopped" {
+ #expect(fixture.store.sessions.isEmpty)
+ #expect(fixture.store.activitySummary.totalBandwidthKbps == 0)
+ } else {
+ #expect(fixture.store.sessions.first?.isPaused == true)
+ #expect(fixture.store.sessions.first?.viewOffset == 9000)
+ }
+ #expect(fixture.requests.value == 2)
+}
+
+@MainActor
+@Test func activityPollingCancelsOnSleepAndRefreshesOnWake() async throws {
+ let fixture = try ActivityRefreshFixture()
+ defer { fixture.stop() }
+ await fixture.ready()
+ fixture.store.setActivityVisible(true, consumer: UUID())
+ await waitUntil { fixture.clock.pendingCount == 1 }
+ fixture.store.systemWillSleep()
+ await waitUntil { fixture.clock.pendingCount == 0 }
+ #expect(fixture.clock.pendingCount == 0)
+ fixture.clock.advance(by: .seconds(100))
+ #expect(fixture.requests.value == 1)
+ fixture.store.systemDidWake()
+ await waitUntil { fixture.requests.value >= 2 && fixture.clock.pendingCount == 1 }
+ #expect(fixture.requests.value >= 2)
+ #expect(fixture.clock.pendingCount == 1)
+}
+
+@MainActor
+@Test func activityPollingDiscardsResponseAfterSignOut() async throws {
+ let gate = DispatchSemaphore(value: 0)
+ let fixture = try ActivityRefreshFixture(beforeResponse: { count in
+ if count == 2 { #expect(gate.wait(timeout: .now() + 5) == .success) }
+ })
+ defer { gate.signal(); fixture.stop() }
+ await fixture.ready()
+ let manual = fixture.store.refreshNow()
+ await waitUntil { fixture.requests.value == 2 }
+ fixture.stop()
+ gate.signal()
+ await manual.value
+ #expect(fixture.store.sessions.isEmpty)
+ #expect(fixture.store.lastHydratedAt == nil)
+ #expect(fixture.store.activityErrorMessage == nil)
+ #expect(!fixture.store.isLoading)
+ #expect(fixture.clock.pendingCount == 0)
+}
+
+@MainActor
+@Test func activityVisibilityObservesHostingWindowsAndPanelLifetime() async throws {
+ let fixture = try ActivityRefreshFixture()
+ defer { fixture.stop() }
+ await fixture.ready()
+ let first = UUID(), second = UUID()
+ let window = ActivityVisibilityTestWindow()
+ let panel = ActivityVisibilityTestWindow()
+ let firstView = PlexActivityVisibilityView()
+ let secondView = PlexActivityVisibilityView()
+ firstView.isEnabled = true
+ secondView.isEnabled = true
+ firstView.onChange = { fixture.store.setActivityVisible($0, consumer: first) }
+ secondView.onChange = { fixture.store.setActivityVisible($0, consumer: second) }
+ window.contentView = firstView
+ panel.contentView = secondView
+ defer { firstView.stopObserving(); secondView.stopObserving() }
+ window.setVisible(true)
+ await waitUntil { fixture.clock.pendingCount == 1 }
+ #expect(fixture.clock.pendingCount == 1)
+ #expect(!window.isKeyWindow)
+ panel.setVisible(true)
+ // Flush the queued native visibility callbacks before hiding the first window.
+ await Task { @MainActor in }.value
+ window.setVisible(false)
+ await Task { @MainActor in }.value
+ #expect(fixture.clock.pendingCount == 1)
+ panel.setVisible(false)
+ await waitUntil { fixture.clock.pendingCount == 0 }
+ #expect(fixture.clock.pendingCount == 0)
+ fixture.clock.advance(by: .seconds(100))
+ panel.setVisible(true)
+ await waitUntil { fixture.requests.value == 2 && fixture.clock.pendingCount == 1 }
+ #expect(fixture.requests.value == 2)
+ secondView.isEnabled = false
+ secondView.scheduleVisibilityUpdate()
+ await waitUntil { fixture.clock.pendingCount == 0 }
+ #expect(fixture.clock.pendingCount == 0)
+ secondView.isEnabled = true
+ secondView.scheduleVisibilityUpdate()
+ await waitUntil { fixture.clock.pendingCount == 1 }
+ secondView.stopObserving()
+ await waitUntil { fixture.clock.pendingCount == 0 }
+ #expect(fixture.clock.pendingCount == 0)
+}
+
@MainActor
@Test func fullHydrateDropsSessionsWithoutCanonicalSessionKeys() async throws {
let suiteName = "PlexBarTests.fullHydrateDropsSessionsWithoutCanonicalSessionKeys"
@@ -350,6 +561,9 @@ struct PlexSessionStoreTests {
$0.sessions.first?.viewOffset == 2500 && $0.sessions.first?.isPaused == true
}
+ #expect(store.activitySummary.streamCount == 1)
+ #expect(store.lastHydratedAt != nil)
+ #expect(store.lastUpdated != store.lastHydratedAt)
#expect(fullHydrateCounter.value == 1)
#expect(targetedHydrateCounter.value == 0)
}
@@ -605,12 +819,16 @@ struct PlexSessionStoreTests {
await waitForSessionStore(store) { $0.activeStreamCount == 1 }
let activeSession = try #require(store.sessions.first)
+ let retrievedAt = store.lastHydratedAt
await store.terminate(activeSession)
#expect(store.activeStreamCount == 1)
#expect(store.isTerminating(activeSession) == false)
#expect(store.errorMessage?.isEmpty == false)
#expect(sessionsCounter.value >= 2)
+ #expect(store.activityErrorMessage != nil)
+ #expect(store.lastHydratedAt == retrievedAt)
+ #expect(store.activitySummary.streamCount == 1)
}
@MainActor
@@ -666,6 +884,7 @@ struct PlexSessionStoreTests {
#expect(store.isTerminating(activeSession) == false)
#expect(store.errorMessage?.isEmpty == false)
#expect(sessionsCounter.value == 1)
+ #expect(store.activityErrorMessage == nil)
}
@MainActor
@@ -1177,14 +1396,116 @@ struct PlexSessionStoreTests {
}
@MainActor
-@Test func unknownPlayingEventTriggersOneTargetedHydrate() async throws {
- let suiteName = "PlexBarTests.unknownPlayingEventTriggersOneTargetedHydrate"
+@Test(arguments: [true, false], [true, false])
+func capturedEpisodeTransitionReconcilesSessions(
+ receivesStop: Bool,
+ startsWithTranscode: Bool
+) async throws {
+ let suiteName = "PlexBarTests.capturedEpisodeTransition.\(receivesStop).\(startsWithTranscode)"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let requestCount = RequestCounter()
+ let handler = Locked(nil)
+ let pausedSession = sessionJSON(sessionKey: "90", ratingKey: "31475", state: "paused", viewOffset: 1574817)
+ let oldEpisode = sessionJSON(
+ sessionKey: "91", ratingKey: "2832", state: "playing", viewOffset: 1289000,
+ transcodeSessionKey: "/transcode/sessions/transcode-old",
+ type: "episode", title: "Episode 10"
+ )
+ let nextEpisode = sessionJSON(
+ sessionKey: "92", ratingKey: "2833", state: "playing", viewOffset: 0,
+ transcodeSessionKey: "/transcode/sessions/transcode-next",
+ type: "episode", title: "Episode 11"
+ )
+ let session = makeSessionStoreMockSession { request in
+ let url = try #require(request.url)
+ if url.path == "/identity" {
+ return try identityResponse(for: url)
+ }
+ if url.path == "/status/sessions" {
+ #expect(url.query == nil)
+ requestCount.increment()
+ let episode = requestCount.value == 1 ? oldEpisode : nextEpisode
+ return try sessionsResponse(for: url, metadata: [pausedSession, episode])
+ }
+ throw URLError(.unsupportedURL)
+ }
+ let settings = makeSessionStoreSettings(defaults: defaults)
+ let store = makeSessionStore(
+ settings: settings,
+ session: session,
+ eventsClient: PlexSessionEventsClient { _, onEvent in
+ try await onEvent(.connected)
+ handler.withValue { $0 = onEvent }
+ try await Task.sleep(for: .seconds(60))
+ }
+ )
+ defer { stopSessionMonitoring(store: store, settings: settings) }
+ await waitUntil { handler.value != nil }
+ let onEvent = try #require(handler.value)
+ #expect(store.sessions.map(\.canonicalSessionKey) == ["90", "91"])
+
+ // Captured PMS stop/start/progress contracts; opaque transcode IDs are anonymized.
+ if receivesStop {
+ let stopped = Data(#"{"NotificationContainer":{"type":"playing","PlaySessionStateNotification":[{"key":"/library/metadata/2832","ratingKey":"2832","sessionKey":"91","state":"stopped","transcodeSession":"transcode-old","viewOffset":1294000}]}}"#.utf8)
+ let events = PlexSessionEventsClient.decodeEventsIfPossible(from: stopped)
+ #expect(events.count == 1)
+ for event in events {
+ try await onEvent(event)
+ }
+ #expect(store.sessions.map(\.canonicalSessionKey) == ["90"])
+ #expect(requestCount.value == 1)
+ }
+
+ // The observed next-episode start omitted the field. Another real stream included it
+ // from its first notification and never appeared before the decoder was corrected.
+ let transcodeField = startsWithTranscode ? #", "transcodeSession":"transcode-next""# : ""
+ let started = Data("""
+ {"NotificationContainer":{"type":"playing","PlaySessionStateNotification":[
+ {"key":"/library/metadata/2833","ratingKey":"2833","sessionKey":"92","state":"playing","viewOffset":0\(transcodeField)}
+ ]}}
+ """.utf8)
+ let startEvents = PlexSessionEventsClient.decodeEventsIfPossible(from: started)
+ #expect(startEvents.count == 1)
+ for event in startEvents {
+ try await onEvent(event)
+ try await onEvent(event) // Duplicate start must not add a row or refetch.
+ }
+
+ for offset in [9000, 19000] {
+ let progress = Data("""
+ {"NotificationContainer":{"type":"playing","PlaySessionStateNotification":[
+ {"key":"/library/metadata/2833","ratingKey":"2833","sessionKey":"92","state":"playing","transcodeSession":"transcode-next","viewOffset":\(offset)}
+ ]}}
+ """.utf8)
+ let events = PlexSessionEventsClient.decodeEventsIfPossible(from: progress)
+ #expect(events.count == 1)
+ for event in events {
+ try await onEvent(event)
+ }
+ }
+
+ #expect(store.sessions.map(\.canonicalSessionKey) == ["90", "92"])
+ #expect(store.sessions.first?.isPaused == true)
+ #expect(store.sessions.first?.viewOffset == 1574817)
+ #expect(store.sessions.last?.title == "Episode 11")
+ #expect(store.sessions.last?.viewOffset == 19000)
+ #expect(store.sessions.last?.transcodeSessionKey == "/transcode/sessions/transcode-next")
+ #expect(store.activeStreamCount == 2)
+ #expect(store.errorMessage == nil)
+ #expect(requestCount.value == 2)
+}
+
+@MainActor
+@Test func unknownPlayingEventRefreshesTheActiveSessionList() async throws {
+ let suiteName = "PlexBarTests.unknownPlayingEventRefreshesTheActiveSessionList"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defaults.removePersistentDomain(forName: suiteName)
defer { defaults.removePersistentDomain(forName: suiteName) }
let fullHydrateCounter = RequestCounter()
- let targetedHydrateCounter = RequestCounter()
let session = makeSessionStoreMockSession { request in
let url = try #require(request.url)
@@ -1194,12 +1515,10 @@ struct PlexSessionStoreTests {
if url.path == "/status/sessions", url.query == nil {
fullHydrateCounter.increment()
- return try sessionsResponse(for: url, metadata: [])
- }
-
- if url.path == "/status/sessions", url.query?.contains("sessionKey=55") == true {
- targetedHydrateCounter.increment()
- return try sessionsResponse(for: url, metadata: [sessionJSON(sessionKey: "55", ratingKey: "901", state: "playing", viewOffset: 4000)])
+ let metadata = fullHydrateCounter.value == 1
+ ? []
+ : [sessionJSON(sessionKey: "55", ratingKey: "901", state: "playing", viewOffset: 4000)]
+ return try sessionsResponse(for: url, metadata: metadata)
}
throw URLError(.unsupportedURL)
@@ -1226,8 +1545,7 @@ struct PlexSessionStoreTests {
await waitForSessionStore(store) { $0.activeStreamCount == 1 && $0.sessions.first?.canonicalSessionKey == "55" }
- #expect(fullHydrateCounter.value == 1)
- #expect(targetedHydrateCounter.value == 1)
+ #expect(fullHydrateCounter.value == 2)
}
@MainActor
@@ -1281,6 +1599,8 @@ struct PlexSessionStoreTests {
await waitForSessionStore(store) { $0.activeStreamCount == 0 && $0.lastUpdated != nil }
+ #expect(store.activitySummary.streamCount == 0)
+ #expect(store.lastHydratedAt != nil)
#expect(fullHydrateCounter.value == 1)
#expect(targetedHydrateCounter.value == 0)
}
@@ -1399,14 +1719,13 @@ struct PlexSessionStoreTests {
}
@MainActor
-@Test func transcodeIdentityChangeTriggersOneTargetedHydrate() async throws {
- let suiteName = "PlexBarTests.transcodeIdentityChangeTriggersOneTargetedHydrate"
+@Test func transcodeIdentityChangeRefreshesTheActiveSessionList() async throws {
+ let suiteName = "PlexBarTests.transcodeIdentityChangeRefreshesTheActiveSessionList"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defaults.removePersistentDomain(forName: suiteName)
defer { defaults.removePersistentDomain(forName: suiteName) }
let fullHydrateCounter = RequestCounter()
- let targetedHydrateCounter = RequestCounter()
let session = makeSessionStoreMockSession { request in
let url = try #require(request.url)
@@ -1416,22 +1735,16 @@ struct PlexSessionStoreTests {
if url.path == "/status/sessions", url.query == nil {
fullHydrateCounter.increment()
- return try sessionsResponse(for: url, metadata: [
- sessionJSON(sessionKey: "44", ratingKey: "900", state: "playing", viewOffset: 1000)
- ])
- }
-
- if url.path == "/status/sessions", url.query?.contains("sessionKey=44") == true {
- targetedHydrateCounter.increment()
- return try sessionsResponse(for: url, metadata: [
- sessionJSON(
- sessionKey: "44",
- ratingKey: "900",
- state: "playing",
- viewOffset: 1000,
+ let metadata = fullHydrateCounter.value == 1
+ ? [
+ sessionJSON(sessionKey: "44", ratingKey: "900", state: "playing", viewOffset: 1000),
+ sessionJSON(sessionKey: "55", ratingKey: "901", state: "playing", viewOffset: 1000)
+ ]
+ : [sessionJSON(
+ sessionKey: "44", ratingKey: "900", state: "playing", viewOffset: 1000,
transcodeSessionKey: "/transcode/sessions/abc"
- )
- ])
+ )]
+ return try sessionsResponse(for: url, metadata: metadata)
}
throw URLError(.unsupportedURL)
@@ -1461,8 +1774,8 @@ struct PlexSessionStoreTests {
$0.sessions.first?.transcodeSessionKey == "/transcode/sessions/abc"
}
- #expect(fullHydrateCounter.value == 1)
- #expect(targetedHydrateCounter.value == 1)
+ #expect(fullHydrateCounter.value == 2)
+ #expect(store.sessions.map(\.canonicalSessionKey) == ["44"])
}
@MainActor
@@ -1633,6 +1946,33 @@ struct PlexSessionStoreTests {
#expect(seenURLs == [localURL])
}
+
+@MainActor
+@Test func activitySummaryClearsImmediatelyWhenServerChanges() async throws {
+ let defaults = try #require(UserDefaults(suiteName: "PlexBarTests.activitySummaryServerChange"))
+ defaults.removePersistentDomain(forName: "PlexBarTests.activitySummaryServerChange")
+ defer { defaults.removePersistentDomain(forName: "PlexBarTests.activitySummaryServerChange") }
+ let session = makeSessionStoreMockSession { request in
+ let url = try #require(request.url)
+ if url.path == "/identity" { return try identityResponse(for: url) }
+ return try sessionsResponse(for: url, metadata: [sessionJSON(sessionKey: "44", ratingKey: "900", state: "playing", viewOffset: 1000)])
+ }
+ let settings = makeSessionStoreSettings(defaults: defaults)
+ let store = makeSessionStore(settings: settings, session: session, eventsClient: PlexSessionEventsClient { _, onEvent in
+ try await onEvent(.connected)
+ try await Task.sleep(for: .seconds(60))
+ })
+ defer { stopSessionMonitoring(store: store, settings: settings) }
+ #expect(store.lastHydratedAt == nil)
+ await waitForSessionStore(store) { $0.activeStreamCount == 1 }
+ #expect(store.lastHydratedAt != nil)
+ settings.selectedServerIdentifier = "another-server"
+ store.didChangeConfiguration()
+ #expect(store.activitySummary.streamCount == 0)
+ #expect(store.lastHydratedAt == nil)
+ #expect(store.activityErrorMessage == nil)
+}
+
}
@MainActor
@@ -1677,7 +2017,8 @@ private func makeSessionStore(
availableServers: [PlexServerResource] = [],
connectionRecheckSleep: @escaping PlexSessionStore.ConnectionRecheckSleep = { duration in
try await Task.sleep(for: duration)
- }
+ },
+ activityClock: PlexActivityRefreshClock = .continuous
) -> PlexSessionStore {
let resolver = PlexConnectionResolver(
client: PlexAPIClient(session: session),
@@ -1721,7 +2062,8 @@ private func makeSessionStore(
client: PlexAPIClient(session: session),
geoIPClient: geoIPClient,
eventsClient: eventsClient,
- connectionRecheckSleep: connectionRecheckSleep
+ connectionRecheckSleep: connectionRecheckSleep,
+ activityClock: activityClock
)
store.didChangeConfiguration()
@@ -1791,7 +2133,9 @@ private func sessionJSON(
playerAddress: String? = nil,
remotePublicAddress: String? = nil,
playerLocal: Bool? = nil,
- playerRelayed: Bool? = nil
+ playerRelayed: Bool? = nil,
+ type: String = "movie",
+ title: String = "Heat"
) -> String {
let sessionIDJSON = includeSessionID ? "\n \"id\": \"\(sessionKey)\"," : ""
let transcodeSessionJSON = transcodeSessionKey.map { key in
@@ -1807,8 +2151,8 @@ private func sessionJSON(
"sessionKey": "\#(sessionKey)",
"ratingKey": "\#(ratingKey)",
"key": "/library/metadata/\#(ratingKey)",
- "type": "movie",
- "title": "Heat",
+ "type": "\#(type)",
+ "title": "\#(title)",
"viewOffset": \#(viewOffset),
"Player": {
"title": "Apple TV",
@@ -1962,3 +2306,129 @@ private final class Locked: @unchecked Sendable {
lock.unlock()
}
}
+
+@MainActor
+private final class ActivityRefreshFixture {
+ let clock = ActivityTestClock()
+ let requests = RequestCounter()
+ let bandwidth = Locked(8000)
+ let invalidResponse = Locked(false)
+ let handler = Locked(nil)
+ let defaults: UserDefaults
+ let suiteName: String
+ let settings: PlexSettingsStore
+ let store: PlexSessionStore
+
+ init(empty: Bool = false, beforeResponse: @escaping @Sendable (Int) -> Void = { _ in }) throws {
+ suiteName = "PlexBarTests.activityRefresh.\(UUID())"
+ defaults = try #require(UserDefaults(suiteName: suiteName))
+ settings = makeSessionStoreSettings(defaults: defaults)
+ let requests = requests, bandwidth = bandwidth, invalidResponse = invalidResponse, handler = handler
+ let session = makeSessionStoreMockSession { request in
+ let url = try #require(request.url)
+ if url.path == "/identity" { return try identityResponse(for: url) }
+ #expect(url.path == "/status/sessions")
+ requests.increment()
+ let metadata = sessionJSON(sessionKey: "44", ratingKey: "900", state: "paused", viewOffset: 1000)
+ .replacingOccurrences(of: "\"location\":", with: "\"bandwidth\": \(bandwidth.value), \"location\":")
+ beforeResponse(requests.value)
+ if invalidResponse.value {
+ let response = try #require(HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil))
+ return (response, Data("invalid JSON".utf8))
+ }
+ return try sessionsResponse(for: url, metadata: empty ? [] : [metadata])
+ }
+ store = makeSessionStore(
+ settings: settings, session: session,
+ eventsClient: PlexSessionEventsClient { _, onEvent in
+ try await onEvent(.connected)
+ handler.withValue { $0 = onEvent }
+ try await Task.sleep(for: .seconds(60))
+ },
+ activityClock: clock.clock
+ )
+ }
+
+ func ready() async {
+ await waitUntil { self.handler.value != nil && self.store.lastHydratedAt != nil }
+ #expect(store.lastHydratedAt != nil)
+ #expect(requests.value == 1)
+ }
+
+ func stop() {
+ stopSessionMonitoring(store: store, settings: settings)
+ defaults.removePersistentDomain(forName: suiteName)
+ }
+}
+
+private final class ActivityTestClock: @unchecked Sendable {
+ private let lock = NSLock()
+ private var instant = ContinuousClock.now
+ private var waiters: [UUID: (ContinuousClock.Instant, CheckedContinuation)] = [:]
+
+ var clock: PlexActivityRefreshClock {
+ PlexActivityRefreshClock(now: { self.now }, sleepUntil: { try await self.sleep(until: $0) })
+ }
+
+ var now: ContinuousClock.Instant {
+ lock.lock()
+ defer { lock.unlock() }
+ return instant
+ }
+
+ var pendingCount: Int {
+ lock.lock()
+ defer { lock.unlock() }
+ return waiters.count
+ }
+
+ func advance(by duration: Duration) {
+ lock.lock()
+ instant += duration
+ let ready = waiters.filter { $0.value.0 <= instant }
+ for id in ready.keys { waiters.removeValue(forKey: id) }
+ lock.unlock()
+ for waiter in ready.values { waiter.1.resume() }
+ }
+
+ private func sleep(until deadline: ContinuousClock.Instant) async throws {
+ let id = UUID()
+ try await withTaskCancellationHandler {
+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in
+ lock.lock()
+ if Task.isCancelled {
+ lock.unlock()
+ continuation.resume(throwing: CancellationError())
+ } else if deadline <= instant {
+ lock.unlock()
+ continuation.resume()
+ } else {
+ waiters[id] = (deadline, continuation)
+ lock.unlock()
+ }
+ }
+ } onCancel: {
+ self.lock.lock()
+ let waiter = self.waiters.removeValue(forKey: id)
+ self.lock.unlock()
+ waiter?.1.resume(throwing: CancellationError())
+ }
+ }
+}
+
+@MainActor
+private final class ActivityVisibilityTestWindow: NSWindow {
+ private var reportedVisible = false
+ override var isVisible: Bool { reportedVisible }
+ override var occlusionState: NSWindow.OcclusionState { reportedVisible ? [.visible] : [] }
+
+ init() {
+ super.init(contentRect: NSRect(x: 0, y: 0, width: 100, height: 100),
+ styleMask: .borderless, backing: .buffered, defer: true)
+ }
+
+ func setVisible(_ visible: Bool) {
+ reportedVisible = visible
+ NotificationCenter.default.post(name: NSWindow.didChangeOcclusionStateNotification, object: self)
+ }
+}
diff --git a/PlexBarTests/PlexSettingsStoreTests.swift b/PlexBarTests/PlexSettingsStoreTests.swift
new file mode 100644
index 0000000..da82d1c
--- /dev/null
+++ b/PlexBarTests/PlexSettingsStoreTests.swift
@@ -0,0 +1,939 @@
+import Foundation
+import Testing
+@testable import PlexBar
+
+@MainActor
+private final class TestLoginItemService: PlexLoginItemControlling {
+ var currentStatus: PlexLoginItemStatus
+ var setEnabledCalls: [Bool] = []
+ var openSystemSettingsCallCount = 0
+ var error: Error?
+
+ init(status: PlexLoginItemStatus) {
+ currentStatus = status
+ }
+
+ func status() -> PlexLoginItemStatus {
+ currentStatus
+ }
+
+ func setEnabled(_ enabled: Bool) throws {
+ setEnabledCalls.append(enabled)
+
+ if let error {
+ throw error
+ }
+
+ currentStatus = enabled ? .enabled : .notRegistered
+ }
+
+ func openSystemSettingsLoginItems() {
+ openSystemSettingsCallCount += 1
+ }
+}
+
+private struct TestLoginItemError: LocalizedError {
+ let errorDescription: String?
+}
+
+private actor RecordingCredentialStore: PlexCredentialPersisting {
+ private var credentials: PlexStoredCredentials
+ private var loadCount = 0
+
+ init(credentials: PlexStoredCredentials) {
+ self.credentials = credentials
+ }
+
+ func loadCredentials() async -> PlexStoredCredentials {
+ loadCount += 1
+ return credentials
+ }
+
+ func replace(_ value: String?, account: String) async {
+ switch account {
+ case KeychainAccounts.userToken:
+ credentials = PlexStoredCredentials(
+ userToken: value ?? "",
+ serverToken: credentials.serverToken
+ )
+ case KeychainAccounts.serverToken:
+ credentials = PlexStoredCredentials(
+ userToken: credentials.userToken,
+ serverToken: value ?? ""
+ )
+ default:
+ break
+ }
+ }
+
+ func recordedLoadCount() -> Int {
+ loadCount
+ }
+}
+
+private struct CredentialStoreTestError: LocalizedError {
+ let errorDescription: String? = "Credential storage is unavailable."
+}
+
+private actor RecoveringCredentialStore: PlexCredentialPersisting {
+ private var credentials: PlexStoredCredentials
+ private var loadFailuresRemaining: Int
+ private var replaceFailuresRemaining: Int
+
+ init(
+ credentials: PlexStoredCredentials,
+ loadFailuresRemaining: Int = 0,
+ replaceFailuresRemaining: Int = 0
+ ) {
+ self.credentials = credentials
+ self.loadFailuresRemaining = loadFailuresRemaining
+ self.replaceFailuresRemaining = replaceFailuresRemaining
+ }
+
+ func loadCredentials() throws -> PlexStoredCredentials {
+ if loadFailuresRemaining > 0 {
+ loadFailuresRemaining -= 1
+ throw CredentialStoreTestError()
+ }
+ return credentials
+ }
+
+ func replace(_ value: String?, account: String) throws {
+ if replaceFailuresRemaining > 0 {
+ replaceFailuresRemaining -= 1
+ throw CredentialStoreTestError()
+ }
+
+ switch account {
+ case KeychainAccounts.userToken:
+ credentials = PlexStoredCredentials(
+ userToken: value ?? "",
+ serverToken: credentials.serverToken
+ )
+ case KeychainAccounts.serverToken:
+ credentials = PlexStoredCredentials(
+ userToken: credentials.userToken,
+ serverToken: value ?? ""
+ )
+ default:
+ break
+ }
+ }
+}
+
+private actor BlockingCredentialStore: PlexCredentialPersisting {
+ private var credentials: PlexStoredCredentials
+ private var shouldBlockNextReplacement = true
+ private var isReplacementBlocked = false
+ private var replacementContinuation: CheckedContinuation?
+
+ init(credentials: PlexStoredCredentials) {
+ self.credentials = credentials
+ }
+
+ func loadCredentials() -> PlexStoredCredentials {
+ credentials
+ }
+
+ func replace(_ value: String?, account: String) async {
+ if shouldBlockNextReplacement {
+ shouldBlockNextReplacement = false
+ isReplacementBlocked = true
+ await withCheckedContinuation { continuation in
+ replacementContinuation = continuation
+ }
+ isReplacementBlocked = false
+ }
+
+ guard account == KeychainAccounts.userToken else {
+ return
+ }
+ credentials = PlexStoredCredentials(
+ userToken: value ?? "",
+ serverToken: credentials.serverToken
+ )
+ }
+
+ func waitUntilReplacementIsBlocked() async {
+ while !isReplacementBlocked {
+ await Task.yield()
+ }
+ }
+
+ func resumeReplacement() {
+ replacementContinuation?.resume()
+ replacementContinuation = nil
+ }
+}
+
+@MainActor
+@Test func defersCredentialAccessUntilAsyncStartup() async throws {
+ let suiteName = "PlexBarTests.defersCredentialAccessUntilAsyncStartup"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ let credentials = PlexStoredCredentials(userToken: "user-token", serverToken: "server-token")
+ let credentialStore = RecordingCredentialStore(credentials: credentials)
+
+ let store = PlexSettingsStore(defaults: defaults, credentialStore: credentialStore)
+
+ let initialLoadCount = await credentialStore.recordedLoadCount()
+ #expect(initialLoadCount == 0)
+ #expect(!store.hasLoadedCredentials)
+ #expect(store.userToken.isEmpty)
+ #expect(store.serverToken.isEmpty)
+
+ await store.loadCredentials()
+
+ let finalLoadCount = await credentialStore.recordedLoadCount()
+ #expect(finalLoadCount == 1)
+ #expect(store.hasLoadedCredentials)
+ #expect(store.userToken == "user-token")
+ #expect(store.serverToken == "server-token")
+}
+
+@MainActor
+@Test func credentialLoadFailureIsRetryableAndNeverBecomesAnEmptyAccount() async throws {
+ let suiteName = "PlexBarTests.credentialLoadFailureIsRetryableAndNeverBecomesAnEmptyAccount"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ let credentialStore = RecoveringCredentialStore(
+ credentials: PlexStoredCredentials(userToken: "user-token", serverToken: "server-token"),
+ loadFailuresRemaining: 1
+ )
+ let store = PlexSettingsStore(defaults: defaults, credentialStore: credentialStore)
+
+ await store.loadCredentials()
+
+ #expect(!store.hasLoadedCredentials)
+ #expect(!store.isLoadingCredentials)
+ #expect(store.userToken.isEmpty)
+ #expect(store.serverToken.isEmpty)
+ #expect(store.credentialLoadingErrorMessage == "Credential storage is unavailable.")
+
+ await store.loadCredentials()
+
+ #expect(store.hasLoadedCredentials)
+ #expect(store.userToken == "user-token")
+ #expect(store.serverToken == "server-token")
+ #expect(store.credentialLoadingErrorMessage == nil)
+}
+
+@MainActor
+@Test func failedDurableTokenWriteDoesNotPublishAuthenticationAndCanRecover() async throws {
+ let suiteName = "PlexBarTests.failedDurableTokenWriteDoesNotPublishAuthenticationAndCanRecover"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ let credentialStore = RecoveringCredentialStore(
+ credentials: .empty,
+ replaceFailuresRemaining: 1
+ )
+ let store = PlexSettingsStore(
+ defaults: defaults,
+ credentialStore: credentialStore,
+ initialCredentials: .empty
+ )
+
+ await #expect(throws: CredentialStoreTestError.self) {
+ try await store.saveAuthenticatedUserToken("new-token")
+ }
+
+ #expect(!store.hasAuthenticatedAccount)
+ #expect(store.userToken.isEmpty)
+ #expect(store.credentialPersistenceErrorMessage == "Credential storage is unavailable.")
+
+ try await store.saveAuthenticatedUserToken("new-token")
+
+ #expect(store.hasAuthenticatedAccount)
+ #expect(store.userToken == "new-token")
+ #expect(store.credentialPersistenceErrorMessage == nil)
+ #expect(try await credentialStore.loadCredentials().userToken == "new-token")
+}
+
+@MainActor
+@Test func cancellationDuringDurableTokenWriteRestoresThePriorCredential() async throws {
+ let suiteName = "PlexBarTests.cancellationDuringDurableTokenWriteRestoresThePriorCredential"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ let priorCredentials = PlexStoredCredentials(userToken: "prior-token", serverToken: "server-token")
+ let credentialStore = BlockingCredentialStore(credentials: priorCredentials)
+ let store = PlexSettingsStore(
+ defaults: defaults,
+ credentialStore: credentialStore,
+ initialCredentials: priorCredentials
+ )
+ let persistenceTask = Task {
+ try await store.saveAuthenticatedUserToken("replacement-token")
+ }
+
+ await credentialStore.waitUntilReplacementIsBlocked()
+ persistenceTask.cancel()
+ await credentialStore.resumeReplacement()
+
+ await #expect(throws: CancellationError.self) {
+ try await persistenceTask.value
+ }
+ #expect(store.userToken == "prior-token")
+ #expect(store.hasAuthenticatedAccount)
+ #expect(await credentialStore.loadCredentials().userToken == "prior-token")
+}
+
+@MainActor
+@Test func laterCredentialSuccessDoesNotHideAnEarlierAccountFailure() async throws {
+ let suiteName = "PlexBarTests.laterCredentialSuccessDoesNotHideAnEarlierAccountFailure"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ let initialCredentials = PlexStoredCredentials(
+ userToken: "user-token",
+ serverToken: "server-token"
+ )
+ let credentialStore = RecoveringCredentialStore(
+ credentials: initialCredentials,
+ replaceFailuresRemaining: 1
+ )
+ let store = PlexSettingsStore(
+ defaults: defaults,
+ credentialStore: credentialStore,
+ initialCredentials: initialCredentials
+ )
+
+ store.clearAuthentication()
+
+ await #expect(throws: CredentialStoreTestError.self) {
+ try await store.waitForCredentialPersistence()
+ }
+ #expect(store.credentialPersistenceErrorMessage == "Credential storage is unavailable.")
+
+ try await store.saveAuthenticatedUserToken("")
+
+ #expect(store.credentialPersistenceErrorMessage == nil)
+}
+
+@MainActor
+@Test func persistsCredentialChangesThroughTheBackgroundStore() async throws {
+ let suiteName = "PlexBarTests.persistsCredentialChangesThroughTheBackgroundStore"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ let credentialStore = RecordingCredentialStore(credentials: .empty)
+ let store = PlexSettingsStore(defaults: defaults, credentialStore: credentialStore)
+ await store.loadCredentials()
+
+ try await store.saveAuthenticatedUserToken(" user-token ")
+ store.serverToken = "server-token"
+ try await store.waitForCredentialPersistence()
+
+ let persistedCredentials = await credentialStore.loadCredentials()
+ #expect(persistedCredentials == PlexStoredCredentials(
+ userToken: "user-token",
+ serverToken: "server-token"
+ ))
+}
+
+@MainActor
+@Test func preservesTheFinalCredentialValueAcrossRapidUpdates() async throws {
+ let suiteName = "PlexBarTests.preservesTheFinalCredentialValueAcrossRapidUpdates"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ let credentialStore = RecordingCredentialStore(credentials: .empty)
+ let store = PlexSettingsStore(defaults: defaults, credentialStore: credentialStore)
+ await store.loadCredentials()
+
+ for index in 0..<64 {
+ try await store.saveAuthenticatedUserToken("token-\(index)")
+ }
+ try await store.saveAuthenticatedUserToken("final-token")
+ try await store.waitForCredentialPersistence()
+
+ let persistedCredentials = await credentialStore.loadCredentials()
+ #expect(persistedCredentials.userToken == "final-token")
+}
+
+@MainActor
+@Test func defaultsHistoryPollInterval() async throws {
+ let suiteName = "PlexBarTests.defaultsHistoryPollInterval"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let store = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+
+ #expect(store.connectionRecheckIntervalSeconds == AppConstants.defaultConnectionRecheckIntervalSeconds)
+ #expect(store.historyPollIntervalSeconds == AppConstants.defaultHistoryPollIntervalSeconds)
+}
+
+@MainActor
+@Test func persistsConfiguredConnectionRecheckInterval() async throws {
+ let suiteName = "PlexBarTests.persistsConfiguredConnectionRecheckInterval"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let store = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+
+ store.connectionRecheckIntervalSeconds = 1_800
+
+ #expect(store.connectionRecheckIntervalSeconds == 1_800)
+
+ let reloadedStore = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+
+ #expect(reloadedStore.connectionRecheckIntervalSeconds == 1_800)
+}
+
+@MainActor
+@Test func persistsConfiguredHistoryPollInterval() async throws {
+ let suiteName = "PlexBarTests.persistsConfiguredHistoryPollInterval"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let store = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+
+ store.historyPollIntervalSeconds = 3_600
+
+ #expect(store.historyPollIntervalSeconds == 3_600)
+
+ let reloadedStore = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+
+ #expect(reloadedStore.historyPollIntervalSeconds == 3_600)
+}
+
+@MainActor
+@Test func persistsLocalAndRemoteVideoQualityIndependently() async throws {
+ let suiteName = "PlexBarTests.persistsLocalAndRemoteVideoQualityIndependently"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let store = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+ #expect(store.localVideoQuality == .original)
+ #expect(store.remoteVideoQuality == .original)
+
+ store.localVideoQuality = .fourK20Mbps
+ store.remoteVideoQuality = .hd4Mbps
+
+ let reloadedStore = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+ #expect(reloadedStore.videoQuality(for: .local) == .fourK20Mbps)
+ #expect(reloadedStore.videoQuality(for: .remote) == .hd4Mbps)
+ #expect(reloadedStore.videoQuality(for: .relay) == .hd4Mbps)
+}
+
+@MainActor
+@Test func downloadPreferencesPersistIndependentlyFromStreamingQuality() async throws {
+ let suiteName = "PlexBarTests.downloadPreferencesPersistIndependentlyFromStreamingQuality"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let store = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+ #expect(store.downloadPreferences == .default)
+
+ store.localVideoQuality = .fourK20Mbps
+ store.remoteVideoQuality = .sd1500Kbps
+ store.downloadVideoQuality = .fullHD8Mbps
+ store.downloadMusicQuality = .kbps256
+ store.downloadSubtitlePreference = .selectable
+
+ let reloadedStore = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+ #expect(reloadedStore.localVideoQuality == .fourK20Mbps)
+ #expect(reloadedStore.remoteVideoQuality == .sd1500Kbps)
+ #expect(reloadedStore.downloadPreferences == PlexDownloadPreferences(
+ videoQuality: .fullHD8Mbps,
+ musicQuality: .kbps256,
+ subtitlePreference: .selectable
+ ))
+
+ defaults.set("unsupported", forKey: "plex.downloadVideoQuality")
+ defaults.set("unsupported", forKey: "plex.downloadMusicQuality")
+ defaults.set("unsupported", forKey: "plex.downloadSubtitlePreference")
+ let normalizedStore = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+ #expect(normalizedStore.downloadPreferences == .default)
+}
+
+@MainActor
+@Test func qualitySuggestionsDefaultToEnabledAndPersist() async throws {
+ let suiteName = "PlexBarTests.qualitySuggestionsDefaultToEnabledAndPersist"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let store = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+ #expect(store.qualitySuggestionsEnabled)
+
+ store.qualitySuggestionsEnabled = false
+
+ let reloadedStore = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+ #expect(!reloadedStore.qualitySuggestionsEnabled)
+}
+
+@MainActor
+@Test func directPlaybackPoliciesDefaultToEnabledAndPersist() async throws {
+ let suiteName = "PlexBarTests.directPlaybackPoliciesDefaultToEnabledAndPersist"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let store = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+ #expect(store.allowsDirectPlay)
+ #expect(store.allowsDirectStream)
+ #expect(!store.forceDirectPlay)
+ #expect(store.playbackStreamingPolicy == .automatic)
+
+ store.allowsDirectPlay = false
+ store.allowsDirectStream = false
+ store.forceDirectPlay = true
+
+ let reloadedStore = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+ #expect(!reloadedStore.allowsDirectPlay)
+ #expect(!reloadedStore.allowsDirectStream)
+ #expect(reloadedStore.forceDirectPlay)
+ #expect(reloadedStore.playbackStreamingPolicy == PlexPlaybackStreamingPolicy(
+ allowsDirectPlay: false,
+ allowsDirectStream: false,
+ forceDirectPlay: true
+ ))
+}
+
+@MainActor
+@Test func cinemaPreplayDefaultsOffPersistsAndNormalizesUnknownValues() async throws {
+ let suiteName = "PlexBarTests.cinemaPreplayDefaultsOffPersistsAndNormalizesUnknownValues"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let store = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+ #expect(store.cinemaPreplayPreference == .off)
+
+ store.cinemaPreplayPreference = .preRollOnly
+ let preRollStore = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+ #expect(preRollStore.cinemaPreplayPreference == .preRollOnly)
+ #expect(preRollStore.cinemaPreplayPreference.extrasPrefixCount == 0)
+
+ preRollStore.cinemaPreplayPreference = .fiveTrailers
+ let trailerStore = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+ #expect(trailerStore.cinemaPreplayPreference == .fiveTrailers)
+ #expect(trailerStore.cinemaPreplayPreference.extrasPrefixCount == 5)
+
+ defaults.set(99, forKey: "plex.cinemaPreplayPreference")
+ let normalizedStore = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+ #expect(normalizedStore.cinemaPreplayPreference == .off)
+ #expect(normalizedStore.cinemaPreplayPreference.extrasPrefixCount == nil)
+}
+
+@MainActor
+@Test func persistsVideoDynamicRangeAndDefaultsUnknownValuesToAutomatic() async throws {
+ let suiteName = "PlexBarTests.persistsVideoDynamicRangeAndDefaultsUnknownValuesToAutomatic"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let store = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+ #expect(store.videoDynamicRange == .automatic)
+
+ store.videoDynamicRange = .constrainedHigh
+
+ let reloadedStore = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+ #expect(reloadedStore.videoDynamicRange == .constrainedHigh)
+
+ defaults.set("unsupported", forKey: "plex.videoDynamicRange")
+ let normalizedStore = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+ #expect(normalizedStore.videoDynamicRange == .automatic)
+}
+
+@MainActor
+@Test func persistsVideoScalingAndDefaultsUnknownValuesToFit() async throws {
+ let suiteName = "PlexBarTests.persistsVideoScalingAndDefaultsUnknownValuesToFit"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let store = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+ #expect(store.videoScalingMode == .fit)
+
+ store.videoScalingMode = .fill
+
+ let reloadedStore = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+ #expect(reloadedStore.videoScalingMode == .fill)
+
+ defaults.set("unsupported", forKey: "plex.videoScalingMode")
+ let normalizedStore = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+ #expect(normalizedStore.videoScalingMode == .fit)
+}
+
+@MainActor
+@Test func persistsEpisodeSpoilerPolicyAndDefaultsUnknownValuesToOff() async throws {
+ let suiteName = "PlexBarTests.persistsEpisodeSpoilerPolicyAndDefaultsUnknownValuesToOff"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let store = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+ #expect(store.episodeSpoilerPolicy == .off)
+
+ store.episodeSpoilerPolicy = .unwatchedEpisodes
+
+ let reloadedStore = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+ #expect(reloadedStore.episodeSpoilerPolicy == .unwatchedEpisodes)
+
+ defaults.set("unsupported", forKey: "plex.episodeSpoilerPolicy")
+ let normalizedStore = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+ #expect(normalizedStore.episodeSpoilerPolicy == .off)
+}
+
+@MainActor
+@Test func persistsAutoplayPreferencesAndNormalizesUnknownValues() async throws {
+ let suiteName = "PlexBarTests.persistsAutoplayPreferencesAndNormalizesUnknownValues"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let store = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+ #expect(store.autoplayUpNext)
+ #expect(store.autoplayCountdown == .tenSeconds)
+ #expect(store.passoutProtection == .twoHours)
+ #expect(store.rewindOnResume == .none)
+
+ store.autoplayUpNext = false
+ store.autoplayCountdown = .thirtySeconds
+ store.passoutProtection = .threeHours
+ store.rewindOnResume = PlexRewindOnResume(seconds: 17)
+
+ let reloadedStore = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+ #expect(!reloadedStore.autoplayUpNext)
+ #expect(reloadedStore.autoplayCountdown == .thirtySeconds)
+ #expect(reloadedStore.passoutProtection == .threeHours)
+ #expect(reloadedStore.rewindOnResume == PlexRewindOnResume(seconds: 17))
+
+ defaults.set(11, forKey: "plex.autoplayCountdown")
+ defaults.set(101, forKey: "plex.passoutProtection")
+ defaults.set(45, forKey: "plex.rewindOnResumeSeconds")
+ let normalizedStore = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+ #expect(normalizedStore.autoplayCountdown == .tenSeconds)
+ #expect(normalizedStore.passoutProtection == .twoHours)
+ #expect(normalizedStore.rewindOnResume == PlexRewindOnResume(seconds: 30))
+}
+
+@MainActor
+@Test func persistsPlaybackMarkerPreferencesAndDefaultsUnknownValuesToManual() async throws {
+ let suiteName = "PlexBarTests.persistsPlaybackMarkerPreferencesAndDefaultsUnknownValuesToManual"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let store = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+ #expect(store.skipIntroBehavior == .manually)
+ #expect(store.skipAdsBehavior == .manually)
+ #expect(store.skipCreditsBehavior == .manually)
+
+ store.skipIntroBehavior = .automatically
+ store.skipAdsBehavior = .disabled
+ store.skipCreditsBehavior = .automatically
+
+ let reloadedStore = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+ #expect(reloadedStore.playbackMarkerPreferences == PlexPlaybackMarkerPreferences(
+ intro: .automatically,
+ ads: .disabled,
+ credits: .automatically
+ ))
+
+ defaults.set("unsupported", forKey: "plex.skipIntroBehavior")
+ defaults.set("unsupported", forKey: "plex.skipAdsBehavior")
+ defaults.set("unsupported", forKey: "plex.skipCreditsBehavior")
+ let normalizedStore = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+ #expect(normalizedStore.skipIntroBehavior == .manually)
+ #expect(normalizedStore.skipAdsBehavior == .manually)
+ #expect(normalizedStore.skipCreditsBehavior == .manually)
+}
+
+@MainActor
+@Test func reusesPersistedClientIdentifier() async throws {
+ let suiteName = "PlexBarTests.reusesPersistedClientIdentifier"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ defaults.set("existing-client-id", forKey: "plex.clientIdentifier")
+
+ let store = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+
+ #expect(store.clientIdentifier == "existing-client-id")
+}
+
+@MainActor
+@Test func persistsRegisteredJWTKeyIdentity() async throws {
+ let suiteName = "PlexBarTests.persistsRegisteredJWTKeyIdentity"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let store = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+ #expect(store.registeredJWTKeyID == nil)
+
+ store.markJWTKeyRegistered(keyID: "device-key")
+
+ let reloadedStore = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)")
+ )
+
+ #expect(reloadedStore.registeredJWTKeyID == "device-key")
+}
+
+@MainActor
+@Test func clearingAuthenticationPreservesDeviceIdentityState() async throws {
+ let suiteName = "PlexBarTests.clearingAuthenticationPreservesDeviceIdentityState"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let credentialStore = RecordingCredentialStore(credentials: .empty)
+ let store = PlexSettingsStore(
+ defaults: defaults,
+ credentialStore: credentialStore
+ )
+ let initialClientIdentifier = store.clientIdentifier
+ store.markJWTKeyRegistered(keyID: "device-key")
+
+ try await store.saveAuthenticatedUserToken("user-token")
+ store.serverToken = "server-token"
+ store.selectedServerIdentifier = "server-id"
+ store.selectedServerName = "Server"
+ store.cachedConnectionURLString = "http://plex.local:32400"
+ store.cachedConnectionKind = .local
+
+ store.clearAuthentication()
+ try await store.waitForCredentialPersistence()
+
+ #expect(store.clientIdentifier == initialClientIdentifier)
+ #expect(store.registeredJWTKeyID == "device-key")
+ #expect(store.userToken.isEmpty)
+ #expect(store.serverToken.isEmpty)
+ #expect(store.selectedServerIdentifier == nil)
+ #expect(store.selectedServerName == nil)
+ #expect(store.cachedConnectionURLString.isEmpty)
+ #expect(store.cachedConnectionKind == nil)
+
+ let persistedCredentials = await credentialStore.loadCredentials()
+ #expect(persistedCredentials == .empty)
+}
+
+@MainActor
+@Test func loadsOpenAtLoginStatusFromService() async throws {
+ let suiteName = "PlexBarTests.loadsOpenAtLoginStatusFromService"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let loginItemService = TestLoginItemService(status: .requiresApproval)
+ let store = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)"),
+ loginItemService: loginItemService
+ )
+
+ #expect(store.openAtLoginStatus == .requiresApproval)
+ #expect(store.opensAtLogin)
+ #expect(store.openAtLoginRequiresApproval)
+}
+
+@MainActor
+@Test func enablesOpenAtLoginThroughService() async throws {
+ let suiteName = "PlexBarTests.enablesOpenAtLoginThroughService"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let loginItemService = TestLoginItemService(status: .notRegistered)
+ let store = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)"),
+ loginItemService: loginItemService
+ )
+
+ store.setOpenAtLogin(true)
+
+ #expect(loginItemService.setEnabledCalls == [true])
+ #expect(store.openAtLoginStatus == .enabled)
+ #expect(store.opensAtLogin)
+ #expect(store.openAtLoginErrorMessage == nil)
+}
+
+@MainActor
+@Test func recordsOpenAtLoginToggleFailure() async throws {
+ let suiteName = "PlexBarTests.recordsOpenAtLoginToggleFailure"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let loginItemService = TestLoginItemService(status: .notRegistered)
+ loginItemService.error = TestLoginItemError(errorDescription: "Launch denied by user.")
+
+ let store = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)"),
+ loginItemService: loginItemService
+ )
+
+ store.setOpenAtLogin(true)
+
+ #expect(loginItemService.setEnabledCalls == [true])
+ #expect(store.openAtLoginStatus == .notRegistered)
+ #expect(store.openAtLoginErrorMessage == "PlexBar could not enable Open at Login. Launch denied by user.")
+}
+
+@MainActor
+@Test func opensLoginItemsSystemSettingsThroughService() async throws {
+ let suiteName = "PlexBarTests.opensLoginItemsSystemSettingsThroughService"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let loginItemService = TestLoginItemService(status: .requiresApproval)
+ let store = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)"),
+ loginItemService: loginItemService
+ )
+
+ store.openLoginItemsSystemSettings()
+
+ #expect(loginItemService.openSystemSettingsCallCount == 1)
+}
+
+@MainActor
+@Test func refreshingOpenAtLoginStatusClearsStaleErrorMessage() async throws {
+ let suiteName = "PlexBarTests.refreshingOpenAtLoginStatusClearsStaleErrorMessage"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let loginItemService = TestLoginItemService(status: .notRegistered)
+ loginItemService.error = TestLoginItemError(errorDescription: "Launch denied by user.")
+
+ let store = PlexSettingsStore(
+ defaults: defaults,
+ keychain: KeychainStore(service: "tests.\(suiteName)"),
+ loginItemService: loginItemService
+ )
+
+ store.setOpenAtLogin(true)
+ #expect(store.openAtLoginErrorMessage == "PlexBar could not enable Open at Login. Launch denied by user.")
+
+ loginItemService.error = nil
+ loginItemService.currentStatus = .enabled
+
+ store.refreshOpenAtLoginStatus()
+
+ #expect(store.openAtLoginStatus == .enabled)
+ #expect(store.openAtLoginErrorMessage == nil)
+}
diff --git a/Tests/PlexBarTests/PlexSystemLifecycleObserverTests.swift b/PlexBarTests/PlexSystemLifecycleObserverTests.swift
similarity index 76%
rename from Tests/PlexBarTests/PlexSystemLifecycleObserverTests.swift
rename to PlexBarTests/PlexSystemLifecycleObserverTests.swift
index d5c2a37..753c22a 100644
--- a/Tests/PlexBarTests/PlexSystemLifecycleObserverTests.swift
+++ b/PlexBarTests/PlexSystemLifecycleObserverTests.swift
@@ -7,13 +7,17 @@ import Testing
@Test func systemWakeNotificationRunsWakeHandler() async throws {
let notificationCenter = NotificationCenter()
let counter = LifecycleObserverCounter()
+ let sleepCounter = LifecycleObserverCounter()
do {
let observer = PlexSystemLifecycleObserver(
- notificationCenter: notificationCenter
+ notificationCenter: notificationCenter,
+ onWillSleep: { sleepCounter.increment() }
) {
counter.increment()
}
+ notificationCenter.post(name: NSWorkspace.willSleepNotification, object: nil)
+ #expect(sleepCounter.value == 1)
notificationCenter.post(name: NSWorkspace.didWakeNotification, object: nil)
await waitForLifecycleObserver {
@@ -24,6 +28,8 @@ import Testing
withExtendedLifetime(observer) {}
}
+ notificationCenter.post(name: NSWorkspace.willSleepNotification, object: nil)
+ #expect(sleepCounter.value == 1)
notificationCenter.post(name: NSWorkspace.didWakeNotification, object: nil)
try await Task.sleep(for: .milliseconds(50))
diff --git a/PlexBarTests/PlexTimelineRequestParametersTests.swift b/PlexBarTests/PlexTimelineRequestParametersTests.swift
new file mode 100644
index 0000000..ce172fb
--- /dev/null
+++ b/PlexBarTests/PlexTimelineRequestParametersTests.swift
@@ -0,0 +1,69 @@
+import Foundation
+import Testing
+@testable import PlexBar
+
+struct PlexTimelineRequestParametersTests {
+ @Test
+ func activeTimelineClampsPositionsAndOmitsStoppedOnlyFields() {
+ let update = PlexTimelineUpdate(
+ ratingKey: "42",
+ state: .playing,
+ time: -250,
+ duration: -1,
+ sessionIdentifier: "session-1",
+ continuing: true
+ )
+
+ let values = values(for: update)
+
+ #expect(values["key"] == "/library/metadata/42")
+ #expect(values["ratingKey"] == "42")
+ #expect(values["state"] == "playing")
+ #expect(values["time"] == "0")
+ #expect(values["duration"] == "0")
+ #expect(values["continuing"] == nil)
+ #expect(values["offline"] == nil)
+ }
+
+ @Test
+ func stoppedTimelineCarriesQueueContinuityAndOfflineState() {
+ let update = PlexTimelineUpdate(
+ ratingKey: "episode-9",
+ state: .stopped,
+ time: 90_000,
+ duration: 120_000,
+ sessionIdentifier: "session-2",
+ playQueueItemID: "queue-7",
+ continuing: true,
+ offline: true
+ )
+
+ let values = values(for: update)
+
+ #expect(values["playQueueItemID"] == "queue-7")
+ #expect(values["continuing"] == "1")
+ #expect(values["offline"] == "1")
+ }
+
+ @Test
+ func stoppedTimelineExplicitlyReportsThatPlaybackWillNotContinue() {
+ let update = PlexTimelineUpdate(
+ ratingKey: "movie-4",
+ state: .stopped,
+ time: 7_200_000,
+ duration: 7_200_000,
+ sessionIdentifier: "session-3",
+ continuing: false
+ )
+
+ #expect(values(for: update)["continuing"] == "0")
+ }
+
+ private func values(for update: PlexTimelineUpdate) -> [String: String] {
+ Dictionary(
+ uniqueKeysWithValues: PlexTimelineRequestParameters(update: update)
+ .queryItems
+ .compactMap { item in item.value.map { (item.name, $0) } }
+ )
+ }
+}
diff --git a/PlexBarTests/PlexURLBuilderTests.swift b/PlexBarTests/PlexURLBuilderTests.swift
new file mode 100644
index 0000000..8b35c1d
--- /dev/null
+++ b/PlexBarTests/PlexURLBuilderTests.swift
@@ -0,0 +1,113 @@
+import Foundation
+import Testing
+@testable import PlexBar
+
+@Test func normalizesServerURLAndDropsTrailingSlash() async throws {
+ let url = PlexURLBuilder.normalizeServerURL("192.168.1.25:32400/")
+
+ #expect(url?.absoluteString == "http://192.168.1.25:32400")
+}
+
+@Test func buildsArtworkURLWithoutEmbeddingToken() async throws {
+ let serverURL = try #require(PlexURLBuilder.normalizeServerURL("http://plex.local:32400"))
+ let imageURL = PlexURLBuilder.mediaURL(
+ serverURL: serverURL,
+ path: "/library/metadata/146/thumb/1715112830"
+ )
+
+ #expect(imageURL?.absoluteString == "http://plex.local:32400/library/metadata/146/thumb/1715112830")
+}
+
+@Test func appendsToReturnedEndpointPathWithoutDroppingItsQueryPairs() async throws {
+ let serverURL = try #require(PlexURLBuilder.normalizeServerURL("https://plex.local:32400/base"))
+ let endpointURL = PlexURLBuilder.endpointURL(
+ serverURL: serverURL,
+ path: "/provider/play-queue/?source=library&scope=audio",
+ appendingPathComponent: "92"
+ )
+
+ #expect(endpointURL?.absoluteString == "https://plex.local:32400/base/provider/play-queue/92?source=library&scope=audio")
+}
+
+@Test func refusesToAppendToAnAbsoluteReturnedEndpoint() async throws {
+ let serverURL = try #require(PlexURLBuilder.normalizeServerURL("https://plex.local:32400"))
+
+ #expect(PlexURLBuilder.endpointURL(
+ serverURL: serverURL,
+ path: "https://other.example/play-queue",
+ appendingPathComponent: "92"
+ ) == nil)
+}
+
+@Test func refusesAnAbsoluteReturnedEndpointWithoutPathAppending() async throws {
+ let serverURL = try #require(PlexURLBuilder.normalizeServerURL("https://plex.local:32400"))
+
+ #expect(PlexURLBuilder.endpointURL(
+ serverURL: serverURL,
+ path: "https://other.example/provider/timeline?source=library"
+ ) == nil)
+}
+
+@Test func appendsMultipleComponentsToReturnedEndpointWithoutDroppingItsQueryPairs() async throws {
+ let serverURL = try #require(PlexURLBuilder.normalizeServerURL("https://plex.local:32400"))
+ let endpointURL = PlexURLBuilder.endpointURL(
+ serverURL: serverURL,
+ path: "/provider/metadata/?source=library",
+ appendingPathComponents: ["42", "refresh"]
+ )
+
+ #expect(endpointURL?.absoluteString == "https://plex.local:32400/provider/metadata/42/refresh?source=library")
+}
+
+@Test func buildsTranscodedArtworkURL() async throws {
+ let serverURL = try #require(PlexURLBuilder.normalizeServerURL("https://plex.local:32400"))
+ let imageURL = PlexURLBuilder.transcodedArtworkURL(
+ serverURL: serverURL,
+ path: "/library/metadata/146/thumb/1715112830",
+ width: 176,
+ height: 264
+ )
+
+ #expect(imageURL?.absoluteString == "https://plex.local:32400/photo/:/transcode?url=/library/metadata/146/thumb/1715112830&width=176&height=264&minSize=1&upscale=1&format=jpeg")
+}
+
+@Test func buildsAspectPreservingPhotoURLWithoutUpscaling() async throws {
+ let serverURL = try #require(PlexURLBuilder.normalizeServerURL("https://plex.local:32400"))
+ let imageURL = PlexURLBuilder.transcodedPhotoURL(
+ serverURL: serverURL,
+ path: "/library/parts/700/1715112830/file.jpeg",
+ width: 2_200,
+ height: 1_466
+ )
+
+ #expect(imageURL?.absoluteString == "https://plex.local:32400/photo/:/transcode?url=/library/parts/700/1715112830/file.jpeg&width=2200&height=1466&minSize=0&upscale=0&rotate=1&quality=-1&format=jpeg")
+ #expect(PlexURLBuilder.transcodedPhotoURL(
+ serverURL: serverURL,
+ path: "/library/parts/700/file.jpeg",
+ width: 0,
+ height: 1_000
+ ) == nil)
+}
+
+@Test func buildsPlexAuthURLWithPinCode() async throws {
+ let clientContext = PlexClientContext(clientIdentifier: "client-123")
+ let authURL = try #require(clientContext.authURL(for: "pin-code"))
+ let absoluteString = authURL.absoluteString
+
+ #expect(absoluteString.contains(PlexRemoteService.authAppBaseURL.absoluteString + "/auth/#!?"))
+ #expect(absoluteString.contains("clientID=client-123"))
+ #expect(absoluteString.contains("code=pin-code"))
+ #expect(absoluteString.contains("context%5Bdevice%5D%5BdeviceName%5D=Mac%20(PlexBar)"))
+ #expect(!absoluteString.contains("forwardUrl="))
+}
+
+@Test func buildsDirectPlexLinkURLForDeviceAuthorization() throws {
+ let url = try #require(PlexRemoteService.linkURL(pinCode: " AB12 \n"))
+ let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false))
+
+ #expect(components.scheme == "https")
+ #expect(components.host == "plex.tv")
+ #expect(components.path == "/link/")
+ #expect(components.queryItems == [URLQueryItem(name: "pin", value: "AB12")])
+ #expect(PlexRemoteService.linkURL(pinCode: " ") == nil)
+}
diff --git a/PlexBarTests/PlexVideoFullScreenKeyboardTests.swift b/PlexBarTests/PlexVideoFullScreenKeyboardTests.swift
new file mode 100644
index 0000000..b191858
--- /dev/null
+++ b/PlexBarTests/PlexVideoFullScreenKeyboardTests.swift
@@ -0,0 +1,68 @@
+import AppKit
+import Testing
+@testable import PlexBar
+
+@MainActor
+struct PlexVideoFullScreenKeyboardTests {
+ @Test func fTogglesVideoAndEscapeOnlyExitsFullScreen() {
+ #expect(action("f", fullScreen: false) == .enter)
+ #expect(action("f", fullScreen: true) == .exit)
+ #expect(action("\u{1b}", fullScreen: true) == .exit)
+ #expect(action("\u{1b}", fullScreen: false) == nil)
+ #expect(action(" ", fullScreen: false) == nil)
+ }
+
+ @Test func typingAndSystemShortcutsAreNotIntercepted() {
+ for fullScreen in [false, true] {
+ #expect(action("f", fullScreen: fullScreen, editing: true) == nil)
+ #expect(action("\u{1b}", fullScreen: fullScreen, editing: true) == nil)
+ for modifiers: NSEvent.ModifierFlags in [
+ .command, [.control, .command], .function, .control, .option, .shift,
+ ] {
+ #expect(action("f", fullScreen: fullScreen, modifiers: modifiers) == nil)
+ }
+ }
+ #expect(action("F", fullScreen: false, modifiers: .capsLock) == .enter)
+ }
+
+ @Test func playbackSurvivesBothFullScreenTransitions() {
+ let lifecycle = PlexPlayerPresentationLifecycle()
+ lifecycle.willEnterFullScreen()
+ #expect(lifecycle.isFullScreenTransitioning)
+ #expect(lifecycle.keepsPlaybackAliveWhenViewDisappears)
+ lifecycle.didEnterFullScreen()
+ #expect(!lifecycle.isFullScreenTransitioning)
+ #expect(lifecycle.isFullScreenActive)
+ lifecycle.willExitFullScreen()
+ #expect(lifecycle.isFullScreenTransitioning)
+ #expect(lifecycle.keepsPlaybackAliveWhenViewDisappears)
+ lifecycle.didExitFullScreen()
+ #expect(!lifecycle.isFullScreenTransitioning)
+ #expect(!lifecycle.isFullScreenActive)
+ }
+
+ private func action(
+ _ characters: String,
+ fullScreen: Bool,
+ editing: Bool = false,
+ modifiers: NSEvent.ModifierFlags = []
+ ) -> PlexVideoFullScreenKeyboardHandler.Action? {
+ let event = NSEvent.keyEvent(
+ with: .keyDown,
+ location: .zero,
+ modifierFlags: modifiers,
+ timestamp: 0,
+ windowNumber: 0,
+ context: nil,
+ characters: characters,
+ charactersIgnoringModifiers: characters,
+ isARepeat: false,
+ keyCode: characters == "\u{1b}" ? 53 : 3
+ )!
+ return PlexVideoFullScreenKeyboardHandler.action(
+ for: event,
+ isFullScreenActive: fullScreen,
+ isEditingText: editing
+ )
+ }
+}
diff --git a/PlexBarTests/PlexVideoQualityPreferencesTests.swift b/PlexBarTests/PlexVideoQualityPreferencesTests.swift
new file mode 100644
index 0000000..9ef5132
--- /dev/null
+++ b/PlexBarTests/PlexVideoQualityPreferencesTests.swift
@@ -0,0 +1,25 @@
+import Testing
+@testable import PlexBar
+
+struct PlexVideoQualityPreferencesTests {
+ @Test func selectsHomeQualityOnlyForLocalConnections() {
+ let preferences = PlexVideoQualityPreferences(
+ local: .original,
+ remote: .hd4Mbps
+ )
+
+ #expect(preferences.quality(for: .local) == .original)
+ #expect(preferences.quality(for: .remote) == .hd4Mbps)
+ #expect(preferences.quality(for: .relay) == .hd4Mbps)
+ #expect(preferences.quality(for: nil) == .hd4Mbps)
+ }
+
+ @Test func musicQualityIsOriginalAtHomeAndUsesTheRemoteCeilingEverywhereElse() {
+ let preferences = PlexMusicQualityPreferences(remote: .kbps192)
+
+ #expect(preferences.quality(for: .local) == .original)
+ #expect(preferences.quality(for: .remote) == .kbps192)
+ #expect(preferences.quality(for: .relay) == .kbps192)
+ #expect(preferences.quality(for: nil) == .kbps192)
+ }
+}
diff --git a/Tests/PlexBarTests/RequestTestSupport.swift b/PlexBarTests/RequestTestSupport.swift
similarity index 60%
rename from Tests/PlexBarTests/RequestTestSupport.swift
rename to PlexBarTests/RequestTestSupport.swift
index 930d602..3bd7471 100644
--- a/Tests/PlexBarTests/RequestTestSupport.swift
+++ b/PlexBarTests/RequestTestSupport.swift
@@ -1,8 +1,35 @@
import Foundation
+func requestBodyData(_ request: URLRequest) throws -> Data {
+ if let body = request.httpBody {
+ return body
+ }
+ guard let stream = request.httpBodyStream else {
+ return Data()
+ }
+
+ stream.open()
+ defer { stream.close() }
+
+ var body = Data()
+ var buffer = [UInt8](repeating: 0, count: 4_096)
+ while stream.hasBytesAvailable {
+ let count = stream.read(&buffer, maxLength: buffer.count)
+ guard count >= 0 else {
+ throw stream.streamError ?? URLError(.cannotDecodeContentData)
+ }
+ if count == 0 {
+ break
+ }
+ body.append(buffer, count: count)
+ }
+ return body
+}
+
final class RequestCapture: @unchecked Sendable {
private let lock = NSLock()
private var storedRequest: URLRequest?
+ private var storedRequests: [URLRequest] = []
var request: URLRequest? {
lock.lock()
@@ -10,9 +37,16 @@ final class RequestCapture: @unchecked Sendable {
return storedRequest
}
+ var requests: [URLRequest] {
+ lock.lock()
+ defer { lock.unlock() }
+ return storedRequests
+ }
+
func record(_ request: URLRequest) {
lock.lock()
storedRequest = request
+ storedRequests.append(request)
lock.unlock()
}
}
diff --git a/PlexBarTests/StreamDetailsRevealTests.swift b/PlexBarTests/StreamDetailsRevealTests.swift
new file mode 100644
index 0000000..9c1d46c
--- /dev/null
+++ b/PlexBarTests/StreamDetailsRevealTests.swift
@@ -0,0 +1,115 @@
+import AppKit
+import SwiftUI
+import Testing
+@testable import PlexBar
+
+@MainActor @Observable
+private final class RevealTestState {
+ var expanded = true
+ var heights: [CGFloat] = []
+}
+
+private struct RevealHeightPreference: PreferenceKey {
+ static let defaultValue: CGFloat = 0
+
+ static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
+ value = max(value, nextValue())
+ }
+}
+
+// Reproduce the menu's measured, height-capped scroll container. A conditional
+// details view reports only the final height here and fails the motion checks.
+private struct RevealTestMenu: View {
+ let state: RevealTestState
+ let cardCount: Int
+ let maximumHeight: CGFloat
+ @State private var height: CGFloat = 0
+
+ var body: some View {
+ ScrollView {
+ LazyVStack(spacing: 12) {
+ ForEach(0.. 0.5 else { return }
+ height = size
+ state.heights.append(size)
+ }
+ }
+}
+
+@MainActor @Suite(.serialized)
+struct StreamDetailsRevealTests {
+ @Test(arguments: [CGFloat(220), CGFloat(760)])
+ func panelFollowsExpansionAndCollapse(maximumHeight: CGFloat) async throws {
+ try await verifyReveal(cardCount: 1, maximumHeight: maximumHeight, animated: true)
+ }
+
+ @Test
+ func withoutAnimationChangesHeightImmediately() async throws {
+ try await verifyReveal(cardCount: 1, maximumHeight: 760, animated: false)
+ }
+
+ private func verifyReveal(cardCount: Int, maximumHeight: CGFloat, animated: Bool) async throws {
+ let state = RevealTestState()
+ let host = NSHostingView(rootView: RevealTestMenu(
+ state: state, cardCount: cardCount, maximumHeight: maximumHeight
+ ))
+ let window = NSWindow(
+ contentRect: NSRect(x: -10000, y: -10000, width: 388, height: 760),
+ styleMask: [.borderless], backing: .buffered, defer: false
+ )
+ window.contentView = host
+ window.orderFront(nil)
+ defer {
+ window.orderOut(nil)
+ window.contentView = nil
+ }
+ try await Task.sleep(for: .milliseconds(200))
+
+ let collapsedHeight = CGFloat(cardCount * 132 + (cardCount - 1) * 12)
+ let expandedHeight = collapsedHeight + 168
+ #expect(abs((state.heights.last ?? 0) - expandedHeight) < 1)
+
+ for expanded in [false, true] {
+ state.heights = []
+ withAnimation(animated ? .easeInOut(duration: 0.3) : nil) {
+ state.expanded = expanded
+ }
+ var panelHeights: [CGFloat] = []
+ for _ in 0..<25 {
+ host.layoutSubtreeIfNeeded()
+ panelHeights.append(host.fittingSize.height)
+ try await Task.sleep(for: .milliseconds(20))
+ }
+
+ let target = expanded ? expandedHeight : collapsedHeight
+ #expect(abs((state.heights.last ?? 0) - target) < 1)
+ #expect(abs(host.fittingSize.height - min(target, maximumHeight)) < 1)
+ if animated {
+ #expect(state.heights.contains { $0 > collapsedHeight + 1 && $0 < expandedHeight - 1 })
+ if collapsedHeight < maximumHeight {
+ #expect(panelHeights.contains {
+ $0 > collapsedHeight + 1 && $0 < min(expandedHeight, maximumHeight) - 1
+ })
+ }
+ } else {
+ #expect(state.heights.allSatisfy { abs($0 - target) < 1 })
+ }
+ }
+ }
+}
diff --git a/PlexBarTests/TV/TVConnectionResolutionTests.swift b/PlexBarTests/TV/TVConnectionResolutionTests.swift
new file mode 100644
index 0000000..cb7c8fc
--- /dev/null
+++ b/PlexBarTests/TV/TVConnectionResolutionTests.swift
@@ -0,0 +1,124 @@
+import PlexModels
+#if os(tvOS)
+import Foundation
+import Synchronization
+import Testing
+@testable import PlexBarTV
+
+@Suite(.serialized, .timeLimit(.minutes(1)))
+struct TVConnectionResolutionTests {
+ @Test func unreachableConnectionsPreserveTransportCauses() async throws {
+ let fixture = Fixture(responses: [.failure(.timedOut), .failure(.timedOut)])
+ defer { fixture.close() }
+ do {
+ _ = try await fixture.resolve()
+ Issue.record("Expected connection timeout")
+ } catch let failure as PlexServerConnectionFailure {
+ #expect(failure.failureCodes == [.timedOut, .timedOut])
+ #expect(failure.localizedDescription.contains(URLError(.timedOut).localizedDescription))
+ #expect(failure.localizedDescription.components(separatedBy: URLError(.timedOut).localizedDescription).count == 2)
+ #expect(!failure.localizedDescription.contains("test-token"))
+ }
+ #expect(TVConnectionMockProtocol.requests.withLock { $0.map { $0.url?.host } } == ["local.test", "remote.test"])
+ }
+
+ @Test(arguments: [401, 403])
+ func authenticationFailuresStopResolution(status: Int) async throws {
+ let fixture = Fixture(responses: [.response(status, "{}"), .identity("server")])
+ defer { fixture.close() }
+ do {
+ _ = try await fixture.resolve()
+ Issue.record("Expected authentication failure")
+ } catch let failure as TVPlexError {
+ guard case .badStatus(let receivedStatus) = failure else {
+ Issue.record("Expected HTTP failure, received \(failure)")
+ return
+ }
+ #expect(receivedStatus == status)
+ }
+ #expect(TVConnectionMockProtocol.requests.withLock { $0.count } == 1)
+ }
+
+ @Test func wrongServerIdentityStopsResolution() async throws {
+ let fixture = Fixture(responses: [.identity("wrong-server"), .identity("server")])
+ defer { fixture.close() }
+ do {
+ _ = try await fixture.resolve()
+ Issue.record("Expected identity failure")
+ } catch let failure as TVPlexError {
+ guard case .serverIdentityMismatch(let expected, let actual) = failure else {
+ Issue.record("Expected identity failure, received \(failure)")
+ return
+ }
+ #expect(expected == "server")
+ #expect(actual == "wrong-server")
+ }
+ #expect(TVConnectionMockProtocol.requests.withLock { $0.count } == 1)
+ }
+
+ @Test func reachableAdvertisedConnectionKeepsItsActualKind() async throws {
+ let fixture = Fixture(responses: [.failure(.timedOut), .identity("server")])
+ defer { fixture.close() }
+ let resolved = try await fixture.resolve()
+ #expect(resolved.connection.kind == .remote)
+ #expect(resolved.connection.serverURL.host == "remote.test")
+ }
+
+ private struct Fixture {
+ let client: TVPlexClient
+ let session: URLSession
+
+ init(responses: [TVConnectionMockProtocol.Response]) {
+ TVConnectionMockProtocol.requests.withLock { $0 = [] }
+ TVConnectionMockProtocol.responses.withLock { $0 = responses }
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.protocolClasses = [TVConnectionMockProtocol.self]
+ session = URLSession(configuration: configuration)
+ client = TVPlexClient(session: session)
+ }
+
+ func resolve() async throws -> TVPlexResolvedServer {
+ try await client.resolve(PlexServerResource(
+ id: "server", name: "Test Plex", productVersion: nil, accessToken: "test-token",
+ connections: [
+ .init(uri: URL(string: "https://remote.test")!, local: false, relay: false),
+ .init(uri: URL(string: "https://local.test")!, local: true, relay: false)
+ ]
+ ), clientIdentifier: "resolution-tests")
+ }
+
+ func close() { session.invalidateAndCancel() }
+ }
+}
+
+private final class TVConnectionMockProtocol: URLProtocol, @unchecked Sendable {
+ enum Response: Sendable {
+ case failure(URLError.Code)
+ case response(Int, String)
+
+ static func identity(_ identifier: String) -> Self {
+ .response(200, "{\"MediaContainer\":{\"machineIdentifier\":\"\(identifier)\"}}")
+ }
+ }
+ static let requests = Mutex<[URLRequest]>([])
+ static let responses = Mutex<[Response]>([])
+
+ override class func canInit(with request: URLRequest) -> Bool { true }
+ override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
+ override func startLoading() {
+ Self.requests.withLock { $0.append(request) }
+ let next = Self.responses.withLock { $0.isEmpty ? Response.failure(.unsupportedURL) : $0.removeFirst() }
+ switch next {
+ case .failure(let code):
+ client?.urlProtocol(self, didFailWithError: URLError(code))
+ case .response(let status, let body):
+ guard let url = request.url,
+ let response = HTTPURLResponse(url: url, statusCode: status, httpVersion: nil, headerFields: nil) else { return }
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: Data(body.utf8))
+ client?.urlProtocolDidFinishLoading(self)
+ }
+ }
+ override func stopLoading() {}
+}
+#endif
diff --git a/PlexBarTests/TV/TVHomeTests.swift b/PlexBarTests/TV/TVHomeTests.swift
new file mode 100644
index 0000000..47ba59d
--- /dev/null
+++ b/PlexBarTests/TV/TVHomeTests.swift
@@ -0,0 +1,151 @@
+import PlexModels
+#if os(tvOS)
+import Foundation
+import Synchronization
+import Testing
+@testable import PlexBarTV
+
+@Suite(.serialized)
+struct TVHomeTests {
+ @Test func unifiedFeedReplacesBothLegacyRowsAndPreservesServerOrderAndPaging() async throws {
+ let fixture = Fixture()
+ defer { fixture.close() }
+ let hubs = try await fixture.home()
+ #expect(hubs.map(\.hubIdentifier) == ["continueWatching", "recent.movies", "recent.tv"])
+ let hub = try #require(hubs.first)
+ #expect(hub.metadata.map(\.ratingKey) == ["next", "paused"])
+ #expect(hub.isContinueWatching)
+ #expect(hub.prefersPosterArtwork)
+ #expect(hub.title == "Continuer")
+ #expect(hub.more)
+ #expect(hub.totalSize == 3)
+ let path = try #require(hub.key)
+ let page = try await fixture.page(path: path)
+ #expect(page.items.map(\.ratingKey) == ["last"])
+ #expect(page.totalSize == 3)
+ let requests = HomeProtocol.requests.withLock { $0 }
+ let feed = try #require(requests.first { $0.url?.path == "/custom/continue" })
+ let query = try #require(URLComponents(url: feed.url!, resolvingAgainstBaseURL: false)?.queryItems)
+ #expect(query.contains(URLQueryItem(name: "source", value: "library")))
+ #expect(query.contains(URLQueryItem(name: "count", value: "20")))
+ #expect(feed.value(forHTTPHeaderField: "X-Plex-Token") == "test-token")
+ let expanded = try #require(requests.first { $0.url?.path == "/custom/continue/items" })
+ #expect(expanded.value(forHTTPHeaderField: "X-Plex-Container-Start") == "2")
+ #expect(expanded.url?.query == "source=library")
+ #expect(!requests.contains { $0.url?.path == "/hubs/home/onDeck" })
+ }
+
+ @Test func homeExcludesAudioShelvesAndMixedAudioItemsByMediaType() async throws {
+ let fixture = Fixture(continuation: #"{"MediaContainer":{"Hub":[{"hubIdentifier":"continueWatching","title":"Continue Watching","Metadata":[{"ratingKey":"book","type":"track","title":"Spoken chapter"},{"ratingKey":"next","type":"episode","title":"Next episode"}]}]}}"#)
+ defer { fixture.close() }
+ let hubs = try await fixture.home()
+ #expect(hubs.map(\.hubIdentifier) == ["continueWatching", "recent.movies", "recent.tv"])
+ #expect(hubs.first?.metadata.map(\.ratingKey) == ["next"])
+ #expect(hubs.flatMap(\.metadata).allSatisfy { ["movie", "episode"].contains($0.type) })
+ }
+
+ @Test func emptyUnifiedFeedDoesNotResurrectLegacyItems() async throws {
+ let fixture = Fixture(continuation: #"{"MediaContainer":{"Hub":[{"hubIdentifier":"continueWatching","title":"Continue Watching","Metadata":[]}]}}"#)
+ defer { fixture.close() }
+ let hubs = try await fixture.home()
+ #expect(hubs.map(\.hubIdentifier) == ["recent.movies", "recent.tv"])
+ }
+
+ @Test func failedUnifiedFeedSurfacesTheError() async throws {
+ let fixture = Fixture(status: 503)
+ defer { fixture.close() }
+ do {
+ _ = try await fixture.home()
+ Issue.record("Expected unified feed failure")
+ } catch {
+ #expect(error.localizedDescription.contains("503"))
+ }
+ }
+
+ @Test func missingUnifiedCapabilityDoesNotRequestLegacyHome() async throws {
+ let fixture = Fixture(advertisesContinuation: false)
+ defer { fixture.close() }
+ do {
+ _ = try await fixture.home()
+ Issue.record("Expected missing Continue Watching capability")
+ } catch let error as PlexAPIError {
+ guard case .missingLibraryContinueWatchingFeature = error else {
+ Issue.record("Unexpected error: \(error)")
+ return
+ }
+ }
+ #expect(!HomeProtocol.requests.withLock { $0.contains { $0.url?.path == "/custom/promoted" } })
+ }
+
+ @Test func splitResponseFromUnifiedEndpointIsRejected() async throws {
+ let fixture = Fixture(continuation: HomeProtocol.promoted)
+ defer { fixture.close() }
+ do {
+ _ = try await fixture.home()
+ Issue.record("Expected malformed unified response failure")
+ } catch let error as PlexAPIError {
+ guard case .invalidResponse = error else {
+ Issue.record("Unexpected error: \(error)")
+ return
+ }
+ }
+ }
+
+ private struct Fixture {
+ let session: URLSession
+ let advertisesContinuation: Bool
+ init(continuation: String = HomeProtocol.unified, status: Int = 200, advertisesContinuation: Bool = true) {
+ self.advertisesContinuation = advertisesContinuation
+ HomeProtocol.requests.withLock { $0 = [] }
+ HomeProtocol.response.withLock { $0 = (continuation, status, advertisesContinuation) }
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.protocolClasses = [HomeProtocol.self]
+ session = URLSession(configuration: configuration)
+ }
+ var connection: TVPlexConnection {
+ TVPlexConnection(serverURL: URL(string: "https://plex.test")!, token: "test-token",
+ clientIdentifier: "home-tests", serverIdentifier: "server", kind: .local)
+ }
+ func home() async throws -> [PlexHub] {
+ try await TVPlexClient(session: session).fetchHome(connection: connection)
+ }
+ func page(path: String) async throws -> PlexMediaPage {
+ try await TVPlexClient(session: session).fetchHubPage(path: path, start: 2, connection: connection)
+ }
+ func close() { session.invalidateAndCancel() }
+ }
+}
+
+private final class HomeProtocol: URLProtocol, @unchecked Sendable {
+ static let requests = Mutex<[URLRequest]>([])
+ static let response = Mutex<(String, Int, Bool)>((unified, 200, true))
+ static let unified = #"{"MediaContainer":{"Hub":[{"hubIdentifier":"continueWatching","title":"Continuer","key":"/custom/continue/items?source=library","more":true,"size":2,"totalSize":3,"Metadata":[{"ratingKey":"next","type":"episode","title":"Next episode"},{"ratingKey":"paused","type":"movie","title":"Paused movie","viewOffset":60000}]}]}}"#
+ static let promoted = #"{"MediaContainer":{"Hub":[{"hubIdentifier":"recent.audio","title":"Comedy","Metadata":[{"ratingKey":"book","type":"album","title":"A book"}]},{"hubIdentifier":"recent.music","title":"New releases","Metadata":[{"ratingKey":"musician","type":"artist","title":"An artist"}]},{"hubIdentifier":"recent.movies","title":"Movies","Metadata":[{"ratingKey":"paused","type":"movie","title":"Paused movie"}]},{"hubIdentifier":"home.continue","title":"Old Continue Watching","Metadata":[{"ratingKey":"paused","type":"movie","title":"Paused movie"}]},{"hubIdentifier":"home.onDeck","title":"On Deck","Metadata":[{"ratingKey":"next","type":"episode","title":"Next episode"},{"ratingKey":"stale","type":"episode","title":"Excluded episode"}]},{"hubIdentifier":"continueWatching","title":"Promoted duplicate","Metadata":[{"ratingKey":"stale","type":"episode","title":"Excluded episode"}]},{"hubIdentifier":"recent.tv","title":"TV","Metadata":[{"ratingKey":"next","type":"episode","title":"Next episode"}]}]}}"#
+ override class func canInit(with request: URLRequest) -> Bool { true }
+ override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
+ override func startLoading() {
+ Self.requests.withLock { $0.append(request) }
+ let state = Self.response.withLock { $0 }
+ let json: String
+ var status = 200
+ switch request.url?.path {
+ case "/media/providers":
+ let continuation = state.2 ? #",{"type":"ContinueWatching","key":"/custom/continue?source=library"}"# : ""
+ json = #"{"MediaContainer":{"MediaProvider":[{"identifier":"com.plexapp.plugins.library","Feature":[{"type":"promoted","key":"/custom/promoted?source=library"}\#(continuation)]}]}}"#
+ case "/custom/promoted": json = Self.promoted
+ case "/custom/continue": (json, status) = (state.0, state.1)
+ case "/custom/continue/items":
+ json = #"{"MediaContainer":{"offset":2,"totalSize":3,"Metadata":[{"ratingKey":"last","type":"episode","title":"Last episode"}]}}"#
+ default:
+ json = "{}"
+ status = 404
+ }
+ guard let url = request.url,
+ let response = HTTPURLResponse(url: url, statusCode: status, httpVersion: nil, headerFields: nil) else { return }
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: Data(json.utf8))
+ client?.urlProtocolDidFinishLoading(self)
+ }
+ override func stopLoading() {}
+}
+#endif
diff --git a/PlexBarTests/TV/TVLibraryArtworkTests.swift b/PlexBarTests/TV/TVLibraryArtworkTests.swift
new file mode 100644
index 0000000..c84c744
--- /dev/null
+++ b/PlexBarTests/TV/TVLibraryArtworkTests.swift
@@ -0,0 +1,84 @@
+#if os(tvOS)
+import Foundation
+import Testing
+import Synchronization
+@testable import PlexBarTV
+
+@MainActor
+struct TVLibraryArtworkTests {
+ @Test func librarySummariesSupplyArtworkWhenSectionsHaveNoImages() async throws {
+ LibraryArtworkProtocol.requests.withLock { $0 = [] }
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.protocolClasses = [LibraryArtworkProtocol.self]
+ let session = URLSession(configuration: configuration)
+ defer { session.invalidateAndCancel() }
+ let connection = TVPlexConnection(
+ serverURL: URL(string: "https://plex.test")!, token: "artwork-test-token",
+ clientIdentifier: "artwork-tests", serverIdentifier: "server", kind: .local
+ )
+ let libraries = try await TVPlexClient(session: session).fetchLibraries(connection: connection)
+ #expect(libraries.map(\.id) == ["3", "4", "7", "9"])
+ #expect(libraries.map(\.artworkPath) == ["/art/movie", "/thumb/show", "/art/artist", nil])
+ let requests = LibraryArtworkProtocol.requests.withLock { $0 }
+ #expect(requests.count == 5)
+ #expect(requests.contains { $0.url?.path == "/library/sections/all" })
+ for request in requests where request.url?.path != "/library/sections/all" {
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Container-Start") == "0")
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Container-Size") == "1")
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Token") == "artwork-test-token")
+ #expect(URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems == [URLQueryItem(name: "sort", value: "addedAt:desc")])
+ }
+ }
+
+ @Test func libraryCardUsesServerCompositeInsteadOfGenericResourceArt() async throws {
+ let library = try JSONDecoder().decode(TVPlexLibrary.self, from: Data(#"{"key":"1","title":"Movies","type":"movie","composite":"/library/sections/1/composite/1706626696?width=960","art":"/:/resources/movie-fanart.jpg","thumb":"/:/resources/movie.png"}"#.utf8))
+ #expect(library.artworkPath == "/library/sections/1/composite/1706626696?width=960")
+ let connection = TVPlexConnection(
+ serverURL: URL(string: "https://plex.test")!, token: "artwork-test-token",
+ clientIdentifier: "artwork-tests", serverIdentifier: "server", kind: .local
+ )
+ let client = TVPlexClient()
+ let url = try #require(await client.artworkURL(
+ path: library.artworkPath, width: 960, height: 540,
+ connection: connection, usesOriginalImage: true
+ ))
+ #expect(url.path == "/library/sections/1/composite/1706626696")
+ let query = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems)
+ #expect(query.contains(URLQueryItem(name: "width", value: "960")))
+ #expect(query.contains(URLQueryItem(name: "X-Plex-Token", value: "artwork-test-token")))
+ }
+
+ @Test func blankCompositeDoesNotHideAnExplicitLibraryImage() throws {
+ let library = try JSONDecoder().decode(TVPlexLibrary.self, from: Data(#"{"key":"1","title":"Movies","type":"movie","composite":" ","art":"/library/sections/1/art"}"#.utf8))
+ #expect(library.composite == nil)
+ #expect(library.artworkPath == "/library/sections/1/art")
+ }
+}
+private final class LibraryArtworkProtocol: URLProtocol, @unchecked Sendable {
+ static let requests = Mutex<[URLRequest]>([])
+ override class func canInit(with request: URLRequest) -> Bool { true }
+ override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
+ override func startLoading() {
+ Self.requests.withLock { $0.append(request) }
+ let json: String
+ switch request.url?.path {
+ case "/library/sections/all":
+ json = #"{"MediaContainer":{"Directory":[{"key":"3","title":"Movies","type":"movie"},{"key":"4","title":"TV Shows","type":"show"},{"key":"7","title":"Audiobooks","type":"artist"},{"key":"9","title":"Empty","type":"movie"}]}}"#
+ case "/library/sections/3/all":
+ json = #"{"MediaContainer":{"Metadata":[{"ratingKey":"30","title":"Movie","type":"movie","art":"/art/movie","thumb":"/thumb/movie"}]}}"#
+ case "/library/sections/4/all":
+ json = #"{"MediaContainer":{"Metadata":[{"ratingKey":"40","title":"Show","type":"show","thumb":"/thumb/show"}]}}"#
+ case "/library/sections/7/all":
+ json = #"{"MediaContainer":{"Metadata":[{"ratingKey":"70","title":"Author","type":"artist","art":"/art/artist"}]}}"#
+ default:
+ json = #"{"MediaContainer":{"Metadata":[]}}"#
+ }
+ guard let url = request.url,
+ let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil) else { return }
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: Data(json.utf8))
+ client?.urlProtocolDidFinishLoading(self)
+ }
+ override func stopLoading() {}
+}
+#endif
diff --git a/PlexBarTests/TV/TVMediaDetailTests.swift b/PlexBarTests/TV/TVMediaDetailTests.swift
new file mode 100644
index 0000000..b10af76
--- /dev/null
+++ b/PlexBarTests/TV/TVMediaDetailTests.swift
@@ -0,0 +1,151 @@
+import PlexModels
+#if os(tvOS)
+import Foundation
+import Synchronization
+import Testing
+@testable import PlexBarTV
+
+@Suite(.serialized, .timeLimit(.minutes(1)))
+struct TVMediaDetailTests {
+ @MainActor
+ @Test func anExtraDoesNotRequestItsOwnExtras() async throws {
+ let fixture = Fixture()
+ defer { fixture.close() }
+ let suiteName = "TVMediaDetailTests.\(UUID().uuidString)"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ let store = TVAppStore(client: fixture.client, defaults: defaults)
+ let clip = try item(#"{"ratingKey":"42","type":"clip","title":"Trailer"}"#)
+ #expect(try await store.mediaExtras(for: clip).isEmpty)
+ #expect(TVDetailMockProtocol.requests.withLock { $0.isEmpty })
+ }
+
+ @Test func episodeWithoutCreditsLoadsItsActualSeriesCast() async throws {
+ let fixture = Fixture()
+ defer { fixture.close() }
+ let episode = try item(#"{"ratingKey":"42","type":"episode","title":"Episode","grandparentRatingKey":"7"}"#)
+ let cast = try await fixture.client.fetchEpisodeSeriesCast(for: episode, connection: fixture.connection)
+ let presentation = PlexCastAndCrewPresentation(item: episode, episodeSeriesCast: cast)
+ #expect(presentation.cast.map(\.name) == ["Series Lead"])
+ #expect(presentation.cast.first?.subtitle == "Character")
+ #expect(TVDetailMockProtocol.requests.withLock { $0.map { $0.url?.path } } == ["/library/metadata/7"])
+ }
+
+ @Test func episodeCreditsDoNotTriggerARequestOrGetReplacedBySeriesCredits() async throws {
+ let fixture = Fixture()
+ defer { fixture.close() }
+ let episode = try item(#"{"ratingKey":"42","type":"episode","title":"Episode","grandparentRatingKey":"7","Role":[{"id":8,"tag":"Guest Star","role":"Guest"}]}"#)
+ let cast = try await fixture.client.fetchEpisodeSeriesCast(for: episode, connection: fixture.connection)
+ #expect(cast.isEmpty)
+ #expect(PlexCastAndCrewPresentation(item: episode, episodeSeriesCast: cast).cast.map(\.name) == ["Guest Star"])
+ #expect(TVDetailMockProtocol.requests.withLock { $0.isEmpty })
+ }
+
+ @Test(arguments: [
+ #"{"ratingKey":"8","type":"show","title":"Wrong Series"}"#,
+ #"{"ratingKey":"7","type":"movie","title":"Wrong Type"}"#
+ ])
+ func mismatchedSeriesMetadataIsRejected(metadata: String) async throws {
+ let fixture = Fixture(metadata: metadata)
+ defer { fixture.close() }
+ let episode = try item(#"{"ratingKey":"42","type":"episode","title":"Episode","grandparentRatingKey":"7"}"#)
+ await #expect(throws: TVPlexError.self) {
+ try await fixture.client.fetchEpisodeSeriesCast(for: episode, connection: fixture.connection)
+ }
+ }
+
+ @Test func serverFailureIsNotReportedAsAnEmptyCast() async throws {
+ let fixture = Fixture(status: 500)
+ defer { fixture.close() }
+ let episode = try item(#"{"ratingKey":"42","type":"episode","title":"Episode","grandparentRatingKey":"7"}"#)
+ await #expect(throws: TVPlexError.self) {
+ try await fixture.client.fetchEpisodeSeriesCast(for: episode, connection: fixture.connection)
+ }
+ }
+
+ private func item(_ json: String) throws -> PlexMediaItem {
+ try JSONDecoder().decode(PlexMediaItem.self, from: Data(json.utf8))
+ }
+
+ @Test func extrasUseTheMacOSRouteAndSharedSubtypeLabels() async throws {
+ let fixture = Fixture(body: #"{"MediaContainer":{"Metadata":[{"ratingKey":"91","type":"clip","title":"Interview","subtype":"interview"},{"ratingKey":"90","type":"clip","title":"Trailer","subtype":"trailer"}]}}"#)
+ defer { fixture.close() }
+ let extras = try await fixture.client.fetchMediaExtras(ratingKey: "42", connection: fixture.connection)
+ #expect(extras.map(\.ratingKey) == ["91", "90"])
+ #expect(extras.map(\.subtitle) == ["Interview", "Trailer"])
+ #expect(TVDetailMockProtocol.requests.withLock { $0.first?.url?.path } == "/library/metadata/42/extras")
+ }
+
+ @Test func relatedHubsPreserveServerOrderAndPaginationInformation() async throws {
+ let fixture = Fixture(body: #"{"MediaContainer":{"Hub":[{"hubIdentifier":"empty","title":"Empty","Metadata":[]},{"hubIdentifier":"similar","title":"More Like This","more":true,"totalSize":20,"key":"/library/metadata/42/similar","Metadata":[{"ratingKey":"8","type":"movie","title":"Related"}]}]}}"#)
+ defer { fixture.close() }
+ let hubs = try await fixture.client.fetchRelatedHubs(ratingKey: "42", connection: fixture.connection)
+ #expect(hubs.map(\.title) == ["More Like This"])
+ #expect(hubs.first?.more == true)
+ #expect(hubs.first?.totalSize == 20)
+ let url = try #require(TVDetailMockProtocol.requests.withLock { $0.first?.url })
+ #expect(url.path == "/hubs/metadata/42/related")
+ #expect(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.contains(URLQueryItem(name: "count", value: "12")) == true)
+ }
+
+ @Test(arguments: ["extras", "related"])
+ func discoveryFailureDoesNotBecomeEmptyContent(endpoint: String) async throws {
+ let fixture = Fixture(status: 500)
+ defer { fixture.close() }
+ await #expect(throws: TVPlexError.self) {
+ if endpoint == "extras" {
+ _ = try await fixture.client.fetchMediaExtras(ratingKey: "42", connection: fixture.connection)
+ } else {
+ _ = try await fixture.client.fetchRelatedHubs(ratingKey: "42", connection: fixture.connection)
+ }
+ }
+ }
+
+ private struct Fixture {
+ let client: TVPlexClient
+ let session: URLSession
+ let connection = TVPlexConnection(
+ serverURL: URL(string: "https://plex.test")!, token: "test-token",
+ clientIdentifier: "detail-tests", serverIdentifier: "test-server", kind: .local
+ )
+
+ init(
+ metadata: String = #"{"ratingKey":"7","type":"show","title":"Series","Role":[{"id":1,"tag":"Series Lead","role":"Character"}]}"#,
+ status: Int = 200,
+ body: String? = nil
+ ) {
+ TVDetailMockProtocol.requests.withLock { $0 = [] }
+ TVDetailMockProtocol.response.withLock {
+ $0 = (status, Data((body ?? "{\"MediaContainer\":{\"Metadata\":[\(metadata)]}}").utf8))
+ }
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.protocolClasses = [TVDetailMockProtocol.self]
+ session = URLSession(configuration: configuration)
+ client = TVPlexClient(session: session)
+ }
+
+ func close() { session.invalidateAndCancel() }
+ }
+}
+
+private final class TVDetailMockProtocol: URLProtocol, @unchecked Sendable {
+ static let requests = Mutex<[URLRequest]>([])
+ static let response = Mutex<(Int, Data)>((200, Data()))
+
+ override class func canInit(with request: URLRequest) -> Bool { true }
+ override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
+ override func startLoading() {
+ Self.requests.withLock { $0.append(request) }
+ let (status, data) = Self.response.withLock { $0 }
+ guard let url = request.url,
+ let response = HTTPURLResponse(url: url, statusCode: status, httpVersion: nil, headerFields: nil) else {
+ client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
+ return
+ }
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: data)
+ client?.urlProtocolDidFinishLoading(self)
+ }
+ override func stopLoading() {}
+}
+#endif
diff --git a/PlexBarTests/TV/TVPlaybackPreparationTests.swift b/PlexBarTests/TV/TVPlaybackPreparationTests.swift
new file mode 100644
index 0000000..683f5c0
--- /dev/null
+++ b/PlexBarTests/TV/TVPlaybackPreparationTests.swift
@@ -0,0 +1,940 @@
+import PlexModels
+#if os(tvOS)
+import AVFoundation
+import Foundation
+import Observation
+import os
+import Synchronization
+import Testing
+@testable import PlexBarTV
+
+@MainActor
+@Suite(.serialized, .timeLimit(.minutes(1)))
+struct TVPlaybackPreparationTests {
+ @Test(arguments: ["show", "season"])
+ func hierarchyPlaybackLoadsTheServerSelectedEpisodeAndItsResumePosition(type: String) async throws {
+ let fixture = try await Fixture(hierarchyType: type)
+ defer { fixture.close() }
+ let hierarchy = try Self.item(Self.hierarchyJSON(type: type))
+
+ await prepare(hierarchy, in: fixture.store)
+
+ let request = try #require(fixture.store.playbackRequest)
+ #expect(request.item.ratingKey == "42")
+ #expect(request.item.isPlayable)
+ #expect(request.startTime == 123)
+ #expect(request.queue?.currentItem.playQueueItemID == "502")
+ let queueRequest = try #require(fixture.requests.withLock { $0.first { $0.url?.path == "/provider/queue" } })
+ let query = URLComponents(url: queueRequest.url!, resolvingAgainstBaseURL: false)?.queryItems ?? []
+ #expect(query.contains(URLQueryItem(name: "onDeck", value: "1")))
+ #expect(!query.contains { $0.name == "key" })
+ #expect(fixture.requests.withLock { $0.contains { $0.url?.path == "/library/metadata/42" } })
+ }
+
+ @Test
+ func explicitEpisodeRestartPreservesTheSelectedEpisodeAndStartsAtZero() async throws {
+ let fixture = try await Fixture()
+ defer { fixture.close() }
+ let episode = try Self.item(Self.episodeJSON)
+
+ await prepare(episode, in: fixture.store, resume: false)
+
+ let request = try #require(fixture.store.playbackRequest)
+ #expect(request.item.ratingKey == "42")
+ #expect(request.startTime == 0)
+ #expect(request.queue?.currentItem.ratingKey == "42")
+ let queueRequest = try #require(fixture.requests.withLock { $0.first { $0.url?.path == "/provider/queue" } })
+ let query = URLComponents(url: queueRequest.url!, resolvingAgainstBaseURL: false)?.queryItems ?? []
+ #expect(query.contains(URLQueryItem(name: "key", value: "/library/metadata/42")))
+ #expect(!query.contains { $0.name == "onDeck" })
+ }
+
+ @Test
+ func episodeHubItemWithoutMediaCanResolveAndResume() async throws {
+ let fixture = try await Fixture()
+ defer { fixture.close() }
+ let seed = try Self.item(#"{"ratingKey":"42","title":"Episode from Home","type":"episode"}"#)
+ #expect(!seed.isPlayable)
+ #expect(seed.tvCanStartPlayback)
+
+ await prepare(seed, in: fixture.store)
+
+ let request = try #require(fixture.store.playbackRequest)
+ #expect(request.item.isPlayable)
+ #expect(request.item.ratingKey == "42")
+ #expect(request.startTime == 123)
+ }
+
+ @Test
+ func failedSelectedEpisodeLoadDoesNotPlayAnotherEpisode() async throws {
+ let fixture = try await Fixture(selectedEpisodeStatus: 500)
+ defer { fixture.close() }
+ let show = try Self.item(Self.hierarchyJSON(type: "show"))
+
+ await prepare(show, in: fixture.store)
+
+ #expect(fixture.store.playbackRequest == nil)
+ #expect(fixture.store.errorMessage?.contains("500") == true)
+ #expect(!fixture.requests.withLock { $0.contains { $0.url?.path == "/library/metadata/41" } })
+ }
+
+ @Test
+ func finalPlaybackReportCompletesBeforeBrowseMetadataIsInvalidated() async throws {
+ let gate = TVTimelineResponseGate()
+ let fixture = try await Fixture(timelineGate: gate)
+ defer { fixture.close() }
+ var homeRequests = fixture.homeRequests.makeAsyncIterator()
+ _ = await homeRequests.next() // Initial connection load.
+ let revision = fixture.store.playbackMetadataRevision
+ fixture.store.dismissPlayer()
+ let report = Task {
+ await fixture.store.reportPlayback(PlexTimelineUpdate(
+ ratingKey: "42", state: .stopped, time: 124_000, duration: 1_800_000,
+ sessionIdentifier: "test-session", continuing: false
+ ))
+ }
+ await gate.waitUntilEntered()
+
+ #expect(fixture.store.playbackMetadataRevision == revision)
+ #expect(fixture.requests.withLock { $0.filter { $0.url?.path == "/hubs/promoted" }.count } == 1)
+
+ await gate.release()
+ _ = await report.value
+ #expect(fixture.store.playbackMetadataRevision != revision)
+ _ = await homeRequests.next() // Automatic refresh after the report.
+ }
+
+ @Test
+ func seriesBrowserPreservesSeasonOrderAndRequestsTheChosenSeason() async throws {
+ let fixture = try await Fixture()
+ defer { fixture.close() }
+
+ let seasons = await fixture.store.seriesSeasons(ratingKey: "7")
+ #expect(seasons.map(\.ratingKey) == ["72", "70"])
+ let episodes = await fixture.store.seasonEpisodes(ratingKey: "72")
+
+ #expect(episodes.map(\.ratingKey) == ["42"])
+ #expect(fixture.store.errorMessage == nil)
+ #expect(!fixture.requests.withLock { $0.contains { $0.url?.path == "/library/metadata/70/children" } })
+ }
+
+ @Test
+ func failedSeasonRequestSurfacesTheFailureWithoutLoadingAnotherSeason() async throws {
+ let fixture = try await Fixture(selectedSeasonStatus: 500)
+ defer { fixture.close() }
+
+ let episodes = await fixture.store.seasonEpisodes(ratingKey: "72")
+
+ #expect(episodes.isEmpty)
+ #expect(fixture.store.errorMessage?.contains("500") == true)
+ #expect(!fixture.requests.withLock { $0.contains { $0.url?.path == "/library/metadata/70/children" } })
+ }
+
+ @Test
+ func closingBeforeTheInitialSeekPreservesTheResumePositionInPlex() async throws {
+ let fixture = try await Fixture()
+ defer { fixture.close() }
+ let request = TVPlexPlaybackRequest(item: try Self.item(Self.episodeJSON), startTime: 123)
+ let session = fixture.makePlaybackSession()
+ var timelineRequests = fixture.timelineRequests.makeAsyncIterator()
+ var homeRequests = fixture.homeRequests.makeAsyncIterator()
+ _ = await homeRequests.next()
+
+ await session.prepare(request: request, store: fixture.store)
+ #expect(session.player != nil)
+ session.stop(store: fixture.store, request: request)
+
+ let report = try #require(await timelineRequests.next())
+ let query = URLComponents(url: report.url!, resolvingAgainstBaseURL: false)?.queryItems ?? []
+ #expect(query.contains(URLQueryItem(name: "state", value: "stopped")))
+ #expect(query.contains(URLQueryItem(name: "time", value: "123000")))
+ _ = await homeRequests.next()
+ await fixture.waitForRefreshCompletion()
+ }
+
+ @Test
+ func failureBeforeTheInitialSeekRetriesFromTheSavedPosition() async throws {
+ let fixture = try await Fixture()
+ defer { fixture.close() }
+ let request = TVPlexPlaybackRequest(item: try Self.item(Self.episodeJSON), startTime: 123)
+ let session = fixture.makePlaybackSession()
+ var timelineRequests = fixture.timelineRequests.makeAsyncIterator()
+ var homeRequests = fixture.homeRequests.makeAsyncIterator()
+ _ = await homeRequests.next()
+
+ await session.prepare(request: request, store: fixture.store)
+ let playerItem = try #require(session.player?.currentItem)
+ await withCheckedContinuation { continuation in
+ withObservationTracking {
+ _ = session.errorMessage
+ } onChange: {
+ continuation.resume()
+ }
+ NotificationCenter.default.post(
+ name: AVPlayerItem.failedToPlayToEndTimeNotification,
+ object: playerItem
+ )
+ }
+ #expect(session.canRetryPlayback)
+ _ = try #require(await timelineRequests.next())
+
+ await session.retry(request: request, store: fixture.store)
+ let decisionRequests = fixture.requests.withLock {
+ $0.filter { $0.url?.path == "/video/:/transcode/universal/decision" }
+ }
+ #expect(decisionRequests.count == 2)
+ let retryRequest = try #require(decisionRequests.last)
+ let query = URLComponents(url: retryRequest.url!, resolvingAgainstBaseURL: false)?.queryItems ?? []
+ #expect(query.contains(URLQueryItem(name: "offset", value: "123")))
+ session.stop(store: fixture.store, request: request)
+ _ = try #require(await timelineRequests.next())
+ _ = await homeRequests.next()
+ await fixture.waitForRefreshCompletion()
+ }
+
+ private func prepare(_ item: PlexMediaItem, in store: TVAppStore, resume: Bool = true) async {
+ store.play(item, resume: resume)
+ await withCheckedContinuation { continuation in
+ withObservationTracking {
+ _ = store.playbackRequest
+ _ = store.errorMessage
+ } onChange: {
+ continuation.resume()
+ }
+ }
+ }
+
+ @Test
+ func libraryUsesServerSortsFiltersAndPreservesOptionsOnPagination() async throws {
+ let fixture = try await Fixture()
+ defer { fixture.close() }
+ let library = try JSONDecoder().decode(TVPlexLibrary.self, from: Data(#"{"key":"1","title":"Movies","type":"movie"}"#.utf8))
+ let definition = try await fixture.store.libraryBrowseDefinition(library)
+ #expect(definition.sorts.map(\.title) == ["Title"])
+ #expect(definition.booleanFilters.map(\.id) == ["unwatched"])
+ let genre = try #require(definition.valueFilters.first)
+ let values = try await fixture.store.libraryFilterValues(genre)
+ #expect(values.map(\.title) == ["Comedy", "Drama"])
+ let options = PlexLibraryBrowseOptions(
+ sort: definition.sorts.first?.selection(direction: .descending),
+ enabledBooleanFilterIDs: ["unwatched"], valueFilterSelections: values
+ )
+ await fixture.store.loadLibrary(library, options: options)
+ let first = try #require(fixture.store.libraryItems[library.id]?.last)
+ await fixture.store.loadMoreLibraryItems(library, currentItem: first)
+ let second = try #require(fixture.store.libraryItems[library.id]?.last)
+ await fixture.store.loadMoreLibraryItems(library, currentItem: second)
+ let requests = fixture.requests.withLock { $0.filter { $0.url?.path == "/library/sections/1/all" } }
+ #expect(requests.map { $0.value(forHTTPHeaderField: "X-Plex-Container-Start") } == ["0", "1", "2"])
+ for request in requests {
+ let query = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems ?? []
+ #expect(query.contains(URLQueryItem(name: "sort", value: "titleSort:desc")))
+ #expect(query.contains(URLQueryItem(name: "unwatched", value: "1")))
+ #expect(query.contains(URLQueryItem(name: "genre", value: "10,20")))
+ }
+ #expect(fixture.store.libraryItems[library.id]?.map(\.ratingKey) == ["sorted", "last"])
+ }
+
+ @Test
+ func staleLibraryQueryCannotReplaceNewSortResults() async throws {
+ let gate = TVTimelineResponseGate()
+ let fixture = try await Fixture(libraryGate: gate)
+ defer { fixture.close() }
+ let library = try JSONDecoder().decode(TVPlexLibrary.self, from: Data(#"{"key":"1","title":"Movies","type":"movie"}"#.utf8))
+ let first = Task { await fixture.store.loadLibrary(library) }
+ await gate.waitUntilEntered()
+ let options = PlexLibraryBrowseOptions(sort: PlexLibrarySortSelection(sortID: "titleSort", direction: .ascending, queryValue: "titleSort"))
+ await fixture.store.loadLibrary(library, options: options)
+ #expect(fixture.store.libraryItems[library.id]?.first?.ratingKey == "sorted")
+ await gate.release()
+ await first.value
+ #expect(fixture.store.libraryItems[library.id]?.first?.ratingKey == "sorted")
+ #expect(!fixture.store.isLoading(library))
+ }
+
+ @Test
+ func failedLibraryLoadHasLocalErrorAndCanRetry() async throws {
+ let fixture = try await Fixture(libraryStatus: 500)
+ defer { fixture.close() }
+ let library = try JSONDecoder().decode(TVPlexLibrary.self, from: Data(#"{"key":"1","title":"Movies","type":"movie"}"#.utf8))
+ await fixture.store.loadLibrary(library)
+ #expect(fixture.store.libraryItems[library.id] == nil)
+ #expect(fixture.store.libraryErrors[library.id]?.contains("500") == true)
+ #expect(fixture.store.errorMessage == nil)
+ await fixture.store.retryLibrary(library, options: .default)
+ #expect(fixture.requests.withLock { $0.filter { $0.url?.path == "/library/sections/1/all" }.count } == 2)
+ }
+
+ @Test(arguments: [true, false], ["version", "quality"])
+ func changingPlaybackWhileResumeIsLoadingPreservesPositionAndIntent(autoplay: Bool, change: String) async throws {
+ let fixture = try await Fixture()
+ defer { fixture.close() }
+ let item = try Self.item(#"{"ratingKey":"42","title":"Two versions","type":"episode","duration":1800000,"Media":[{"id":1,"Part":[{"id":2,"key":"/library/parts/2/file.mp4"}]},{"id":3,"Part":[{"id":4,"key":"/library/parts/4/file.mp4"}]}]}"#)
+ let request = TVPlexPlaybackRequest(
+ item: item, startTime: 123, autoplay: autoplay, playbackRate: .oneAndAHalf
+ )
+ let session = fixture.makePlaybackSession()
+ await session.prepare(request: request, store: fixture.store)
+ defer { session.stop(store: fixture.store, request: request) }
+ #expect(session.isPreparingInitialPosition)
+ #expect(session.player?.currentTime().seconds == 0)
+ #expect(session.playbackVersionSelection?.canSelect(1) == true)
+
+ await withCheckedContinuation { continuation in
+ withObservationTracking {
+ _ = fixture.store.playbackRequest
+ } onChange: {
+ continuation.resume()
+ }
+ if change == "version" {
+ session.selectPlaybackVersion(1)
+ } else {
+ session.selectVideoQuality(.hd4Mbps)
+ }
+ }
+
+ let replacement = try #require(fixture.store.playbackRequest)
+ #expect(replacement.startTime == 123)
+ #expect(replacement.autoplay == autoplay)
+ #expect(replacement.playbackRate == .oneAndAHalf)
+ #expect(replacement.item.ratingKey == item.ratingKey)
+ if change == "version" {
+ #expect(replacement.source?.mediaIndex == 1)
+ } else {
+ #expect(replacement.videoQualityOverride == .hd4Mbps)
+ }
+ let stopped = try #require(fixture.requests.withLock { $0.last { $0.url?.path == "/provider/timeline" } })
+ let query = URLComponents(url: stopped.url!, resolvingAgainstBaseURL: false)?.queryItems ?? []
+ #expect(query.contains(URLQueryItem(name: "time", value: "123000")))
+ }
+
+ @Test(arguments: ["next", "prepared-next", "previous", "up-next"])
+ func explicitQueueNavigationStartsPlaybackAndKeepsSelectedQuality(destination: String) async throws {
+ let fixture = try await Fixture()
+ defer { fixture.close() }
+ let item = try Self.item(Self.episodeJSON)
+ let request = TVPlexPlaybackRequest(
+ item: item, queue: try Self.navigationQueue(), startTime: 123,
+ autoplay: false, playbackRate: .oneAndAHalf, videoQualityOverride: .hd4Mbps
+ )
+ let session = fixture.makePlaybackSession()
+ await session.prepare(request: request, store: fixture.store)
+ defer { session.stop(store: fixture.store, request: request) }
+ // Repeat One disables speculative next-item preparation. Explicit navigation
+ // must still behave exactly like an already prepared next item.
+ if destination == "prepared-next" {
+ try await waitFor { session.nextItemTitle == "Selected episode" }
+ } else {
+ session.setRepeatMode(.one)
+ }
+ #expect(session.isPreparingInitialPosition)
+ #expect(session.canGoNext && session.canGoPrevious)
+
+ switch destination {
+ case "next", "prepared-next": session.playNextItem()
+ case "previous": session.playPreviousItem()
+ default: session.playQueuedItem(playQueueItemID: "503")
+ }
+ try await waitFor { fixture.store.playbackRequest != nil }
+ let replacement = try #require(fixture.store.playbackRequest)
+ #expect(replacement.item.ratingKey == (destination == "previous" ? "41" : "43"))
+ #expect(replacement.startTime == (destination == "previous" ? 0 : 123))
+ #expect(replacement.autoplay, "Choosing a queue item is an explicit Play action, as on macOS.")
+ #expect(replacement.playbackRate == .oneAndAHalf)
+ #expect(replacement.videoQualityOverride == .hd4Mbps)
+ #expect(replacement.queue?.currentItem.ratingKey == replacement.item.ratingKey)
+ let stopped = try #require(fixture.requests.withLock { $0.last { $0.url?.path == "/provider/timeline" } })
+ let query = URLComponents(url: stopped.url!, resolvingAgainstBaseURL: false)?.queryItems ?? []
+ #expect(query.contains(URLQueryItem(name: "time", value: "123000")))
+ }
+
+ @Test(arguments: ["continuous", "repeat"])
+ func automaticQueueTransitionsKeepSelectedQuality(transition: String) async throws {
+ let fixture = try await Fixture()
+ defer { fixture.close() }
+ let request = TVPlexPlaybackRequest(
+ item: try Self.item(Self.episodeJSON), queue: try Self.navigationQueue(),
+ startTime: 123, videoQualityOverride: .hd4Mbps
+ )
+ let next: TVPlexPlaybackRequest
+ if transition == "repeat" {
+ next = try await fixture.store.preparedRepeatedQueue(from: request, playbackRate: .oneAndAHalf)
+ } else {
+ next = try #require(await fixture.store.preparedNextPlayback(after: request, playbackRate: .oneAndAHalf))
+ }
+ #expect(next.item.ratingKey == (transition == "repeat" ? "41" : "43"))
+ #expect(next.startTime == (transition == "repeat" ? 0 : 123))
+ #expect(next.autoplay)
+ #expect(next.playbackRate == .oneAndAHalf)
+ #expect(next.videoQualityOverride == .hd4Mbps)
+ }
+
+ @Test(arguments: [true, false])
+ func failedQueueChangePreservesPendingResumeAndCanRetry(autoplay: Bool) async throws {
+ let status = OSAllocatedUnfairLock(initialState: 500)
+ let fixture = try await Fixture(neighborMetadataStatus: status)
+ defer { fixture.close() }
+ let request = TVPlexPlaybackRequest(
+ item: try Self.item(Self.episodeJSON), queue: try Self.navigationQueue(),
+ startTime: 123, autoplay: autoplay, videoQualityOverride: .hd4Mbps
+ )
+ let session = fixture.makePlaybackSession()
+ await session.prepare(request: request, store: fixture.store)
+ session.setRepeatMode(.one)
+ defer { session.stop(store: fixture.store, request: request) }
+ let originalPlayer = try #require(session.player)
+ session.playNextItem()
+ try await waitFor { session.queueNavigationErrorMessage != nil }
+ #expect(session.queueNavigationErrorMessage?.contains("500") == true)
+ #expect(!session.isNavigatingQueue)
+ #expect(session.player === originalPlayer)
+ #expect(session.isPreparingInitialPosition)
+ #expect(originalPlayer.timeControlStatus == .paused, "A failed queue change must not bypass the pending resume seek.")
+ #expect(fixture.store.playbackRequest == nil)
+ #expect(!fixture.requests.withLock { $0.contains { $0.url?.path == "/provider/timeline" } })
+
+ status.withLock { $0 = 200 }
+ session.retryQueueOperation()
+ try await waitFor { fixture.store.playbackRequest != nil }
+ let retried = try #require(fixture.store.playbackRequest)
+ #expect(retried.item.ratingKey == "43")
+ #expect(retried.autoplay)
+ #expect(retried.videoQualityOverride == .hd4Mbps)
+ #expect(session.queueNavigationErrorMessage == nil)
+ }
+
+ @Test(arguments: [true, false])
+ func failedSubtitleAdjustmentPreservesPendingResumeAndCanBeRetried(autoplay: Bool) async throws {
+ let status = OSAllocatedUnfairLock(initialState: 500)
+ let metadata = Self.episodeJSON.replacingOccurrences(
+ of: "\"key\":\"/library/parts/2/file.mp4\"",
+ with: #""key":"/library/parts/2/file.mp4","Stream":[{"id":9,"streamType":3,"codec":"srt","location":"external","selected":true,"offset":0}]"#
+ )
+ let fixture = try await Fixture(episodeMetadata: metadata, subtitleOffsetStatus: status)
+ defer { fixture.close() }
+ let request = TVPlexPlaybackRequest(
+ item: try Self.item(metadata), startTime: 123, autoplay: autoplay,
+ playbackRate: .oneAndAHalf, videoQualityOverride: .hd4Mbps
+ )
+ let session = fixture.makePlaybackSession()
+ await session.prepare(request: request, store: fixture.store)
+ defer { session.stop(store: fixture.store, request: request) }
+ let player = try #require(session.player)
+ #expect(session.subtitleOffsetSelection?.streamID == 9)
+ session.setSubtitleOffset(100)
+ try await waitFor { session.mediaSelectionErrorMessage != nil }
+ #expect(session.mediaSelectionErrorMessage?.contains("500") == true)
+ #expect(session.isPreparingInitialPosition)
+ #expect(session.player === player)
+ #expect(player.timeControlStatus == .paused, "A subtitle request failure must not start video before the saved-position seek.")
+ #expect(fixture.store.playbackRequest == nil)
+
+ status.withLock { $0 = 200 }
+ session.setSubtitleOffset(100)
+ try await waitFor { fixture.store.playbackRequest != nil }
+ let replacement = try #require(fixture.store.playbackRequest)
+ #expect(replacement.startTime == 123)
+ #expect(replacement.autoplay == autoplay)
+ #expect(replacement.playbackRate == .oneAndAHalf)
+ #expect(replacement.videoQualityOverride == .hd4Mbps)
+ #expect(session.mediaSelectionErrorMessage == nil)
+ let update = try #require(fixture.requests.withLock { $0.last { $0.url?.path == "/library/streams/9" } })
+ #expect(update.httpMethod == "PUT")
+ #expect(URLComponents(url: update.url!, resolvingAgainstBaseURL: false)?.queryItems?
+ .contains(URLQueryItem(name: "offset", value: "100")) == true)
+ }
+
+ @Test
+ func failedEndTransitionIgnoresDuplicateEventsUntilExplicitRetry() async throws {
+ let status = OSAllocatedUnfairLock(initialState: 500)
+ let fixture = try await Fixture(neighborMetadataStatus: status)
+ defer { fixture.close() }
+ fixture.store.autoplayNextEpisode = true
+ fixture.store.autoplayCountdown = .immediate
+ let request = TVPlexPlaybackRequest(
+ item: try Self.item(Self.episodeJSON), queue: try Self.navigationQueue(),
+ startTime: 123, videoQualityOverride: .hd4Mbps
+ )
+ let session = fixture.makePlaybackSession()
+ await session.prepare(request: request, store: fixture.store)
+ defer { session.stop(store: fixture.store, request: request) }
+ let playerItem = try #require(session.player?.currentItem)
+ // A failed speculative lookup must still allow one authoritative attempt at EOF.
+ try await waitFor { fixture.requests.withLock { $0.contains { $0.url?.path == "/library/metadata/43" } } }
+ NotificationCenter.default.post(name: AVPlayerItem.didPlayToEndTimeNotification, object: playerItem)
+ try await waitFor { session.queueNavigationErrorMessage != nil }
+ #expect(session.canRetryQueueOperation)
+ #expect(session.queueNavigationErrorMessage?.contains("500") == true)
+ let failedLookupCount = fixture.requests.withLock { $0.filter { $0.url?.path == "/library/metadata/43" }.count }
+ #expect(failedLookupCount == 2)
+
+ NotificationCenter.default.post(name: AVPlayerItem.didPlayToEndTimeNotification, object: playerItem)
+ // Allow the notification's main-actor task and mock response to settle.
+ try await Task.sleep(for: .milliseconds(50))
+ #expect(fixture.requests.withLock { $0.filter { $0.url?.path == "/library/metadata/43" }.count } == failedLookupCount,
+ "Duplicate end notifications must not silently retry a failed transition.")
+ #expect(fixture.store.playbackRequest == nil)
+ #expect(session.canRetryQueueOperation)
+
+ status.withLock { $0 = 200 }
+ session.retryQueueOperation()
+ try await waitFor { fixture.store.playbackRequest != nil }
+ let replacement = try #require(fixture.store.playbackRequest)
+ #expect(replacement.item.ratingKey == "43")
+ #expect(replacement.autoplay)
+ #expect(replacement.videoQualityOverride == .hd4Mbps)
+ }
+
+ @Test(arguments: ["accept", "reject"])
+ func nativeContentProposalRemainsActionableAfterEnd(action: String) async throws {
+ let fixture = try await Fixture()
+ defer { fixture.close() }
+ fixture.store.autoplayNextEpisode = true
+ fixture.store.autoplayCountdown = .fiveSeconds
+ let request = TVPlexPlaybackRequest(
+ item: try Self.item(Self.episodeJSON), queue: try Self.navigationQueue(),
+ startTime: 123, playbackRate: .oneAndAHalf, videoQualityOverride: .hd4Mbps
+ )
+ fixture.store.presentPlayback(request)
+ let session = fixture.makePlaybackSession()
+ await session.prepare(request: request, store: fixture.store)
+ defer { session.stop(store: fixture.store, request: request) }
+ let playerItem = try #require(session.player?.currentItem)
+ try await waitFor { playerItem.nextContentProposal != nil }
+ let proposal = try #require(playerItem.nextContentProposal)
+ #expect(proposal.automaticAcceptanceInterval == 5)
+ #expect(session.shouldPresentContentProposal(proposal))
+ NotificationCenter.default.post(name: AVPlayerItem.didPlayToEndTimeNotification, object: playerItem)
+ try await Task.sleep(for: .milliseconds(30))
+ #expect(fixture.store.playbackRequest?.id == request.id,
+ "AVKit owns the visible proposal until the user accepts or rejects it.")
+ #expect(session.shouldPresentContentProposal(proposal))
+
+ if action == "accept" {
+ session.acceptContentProposal(proposal)
+ try await waitFor { fixture.store.playbackRequest?.id != request.id }
+ let replacement = try #require(fixture.store.playbackRequest)
+ #expect(replacement.item.ratingKey == "43")
+ #expect(replacement.playbackRate == .oneAndAHalf)
+ #expect(replacement.videoQualityOverride == .hd4Mbps)
+ session.acceptContentProposal(proposal)
+ #expect(fixture.store.playbackRequest?.id == replacement.id)
+ } else {
+ session.rejectContentProposal(proposal)
+ try await waitFor { fixture.store.playbackRequest == nil }
+ session.acceptContentProposal(proposal)
+ #expect(fixture.store.playbackRequest == nil,
+ "A stale acceptance must not reopen playback after rejection.")
+ }
+ #expect(!session.shouldPresentContentProposal(proposal))
+ #expect(playerItem.nextContentProposal == nil)
+ }
+
+ @Test
+ func hubBrowsePreservesQueryAndPagesByRawRows() async throws {
+ let fixture = try await Fixture()
+ defer { fixture.close() }
+ let browser = TVHubBrowseStore()
+ let hub = try Self.browseHub()
+ await browser.load(hub: hub, using: fixture.store)
+ #expect(browser.items.map(\.ratingKey) == ["a", "b"])
+ #expect(browser.hasMore)
+ await browser.loadMore(hub: hub, using: fixture.store)
+ #expect(browser.items.map(\.ratingKey) == ["a", "b", "c"])
+ #expect(browser.hasMore)
+ await browser.loadMore(hub: hub, using: fixture.store)
+ #expect(browser.items.map(\.ratingKey) == ["a", "b", "c", "d"])
+ #expect(!browser.hasMore)
+ let requests = fixture.requests.withLock { $0.filter { $0.url?.path == "/hubs/all" } }
+ #expect(requests.map { $0.value(forHTTPHeaderField: "X-Plex-Container-Start") } == ["0", "2", "4"])
+ for request in requests {
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Container-Size") == "60")
+ #expect(URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems?
+ .contains(URLQueryItem(name: "query", value: "star wars")) == true)
+ }
+ }
+
+ @Test(arguments: [0, 2])
+ func hubBrowseRetriesOnlyTheFailedPage(offset: Int) async throws {
+ let status = OSAllocatedUnfairLock(initialState: 500)
+ let fixture = try await Fixture(hubStatus: status, failingHubOffset: offset)
+ defer { fixture.close() }
+ let browser = TVHubBrowseStore()
+ let hub = try Self.browseHub()
+ await browser.load(hub: hub, using: fixture.store)
+ if offset > 0 { await browser.loadMore(hub: hub, using: fixture.store) }
+ #expect(browser.errorMessage?.contains("500") == true)
+ #expect(browser.items.count == offset)
+ #expect(!browser.isLoading)
+ #expect(fixture.store.errorMessage == nil)
+ status.withLock { $0 = 200 }
+ await browser.retry(hub: hub, using: fixture.store)
+ #expect(browser.errorMessage == nil)
+ #expect(browser.items.map(\.ratingKey) == (offset == 0 ? ["a", "b"] : ["a", "b", "c"]))
+ let attempts = fixture.requests.withLock { $0.filter { $0.url?.path == "/hubs/all" } }
+ #expect(attempts.suffix(2).map { $0.value(forHTTPHeaderField: "X-Plex-Container-Start") } == [String(offset), String(offset)])
+ }
+
+ @Test
+ func oldHubResponseCannotOverwriteNewHub() async throws {
+ let gate = TVTimelineResponseGate()
+ let fixture = try await Fixture(hubGate: gate)
+ defer { fixture.close() }
+ let browser = TVHubBrowseStore()
+ let oldHub = try Self.browseHub()
+ let newHub = try Self.browseHub(path: "/hubs/other")
+ let oldLoad = Task { await browser.load(hub: oldHub, using: fixture.store) }
+ await gate.waitUntilEntered()
+ await browser.load(hub: newHub, using: fixture.store)
+ #expect(browser.items.map(\.ratingKey) == ["new"])
+ await gate.release()
+ await oldLoad.value
+ #expect(browser.items.map(\.ratingKey) == ["new"])
+ #expect(!browser.isLoading)
+ #expect(browser.errorMessage == nil)
+ }
+
+ @Test
+ func homePaginationExcludesAudioAndAdvancesByUnfilteredOffsets() async throws {
+ let fixture = try await Fixture(hubIncludesAudio: true)
+ defer { fixture.close() }
+ let browser = TVHubBrowseStore(videoOnly: true)
+ let hub = try Self.browseHub()
+ await browser.load(hub: hub, using: fixture.store)
+ #expect(browser.items.map(\.ratingKey) == ["a"])
+ let first = try #require(browser.items.first)
+ await browser.loadInline(hub: hub, using: fixture.store, after: first)
+ #expect(browser.items.map(\.ratingKey) == ["a", "c", "d"])
+ #expect(!browser.hasMore)
+ let offsets = fixture.requests.withLock { $0.filter { $0.url?.path == "/hubs/all" }
+ .map { $0.value(forHTTPHeaderField: "X-Plex-Container-Start") } }
+ #expect(offsets == ["0", "2", "4"])
+ }
+
+ @Test
+ func inlineHubKeepsPreviewAndLoadsPastOverlappingPages() async throws {
+ let fixture = try await Fixture()
+ defer { fixture.close() }
+ let browser = TVHubBrowseStore()
+ var hub = try Self.browseHub()
+ await browser.load(hub: hub, using: fixture.store)
+ hub.metadata = browser.items
+ let inline = TVHubBrowseStore()
+ #expect(inline.visibleItems(hub: hub, connection: fixture.store.connection).map(\.ratingKey) == ["a", "b"])
+ await inline.loadInline(hub: hub, using: fixture.store, after: hub.metadata[1])
+ #expect(inline.items.map(\.ratingKey) == ["a", "b", "c", "d"])
+ #expect(!inline.hasMore)
+ #expect(inline.errorMessage == nil)
+ }
+
+ @Test
+ func inlineHubFailurePreservesCardsAndRequiresExplicitRetry() async throws {
+ let status = OSAllocatedUnfairLock(initialState: 200)
+ let fixture = try await Fixture(hubStatus: status)
+ defer { fixture.close() }
+ let preview = TVHubBrowseStore()
+ var hub = try Self.browseHub()
+ await preview.load(hub: hub, using: fixture.store)
+ hub.metadata = preview.items
+ status.withLock { $0 = 500 }
+ let inline = TVHubBrowseStore()
+ await inline.loadInline(hub: hub, using: fixture.store, after: hub.metadata[1])
+ #expect(inline.items == hub.metadata)
+ #expect(inline.errorMessage?.contains("500") == true)
+ let requestCount = fixture.requests.withLock { $0.count }
+ await inline.loadInline(hub: hub, using: fixture.store, after: hub.metadata[1])
+ #expect(fixture.requests.withLock { $0.count } == requestCount)
+ status.withLock { $0 = 200 }
+ await inline.retry(hub: hub, using: fixture.store)
+ #expect(inline.errorMessage == nil)
+ #expect(inline.items == hub.metadata)
+ }
+
+ private static func browseHub(path: String = "/hubs/all?query=star%20wars&type=1") throws -> PlexHub {
+ try JSONDecoder().decode(PlexHub.self, from: JSONSerialization.data(withJSONObject: [
+ "hubIdentifier": path, "title": "Movies", "key": path, "more": true, "totalSize": 5,
+ "Metadata": []
+ ]))
+ }
+
+ @Test
+ func searchUsesAdvertisedEndpointAndKeepsFailureLocal() async throws {
+ let status = OSAllocatedUnfairLock(initialState: 500)
+ let fixture = try await Fixture(searchStatus: status)
+ defer { fixture.close() }
+ fixture.store.searchQuery = "star wars"
+ fixture.store.submitSearch()
+ try await waitFor { !fixture.store.isSearching }
+ #expect(fixture.store.searchErrorMessage?.contains("500") == true)
+ #expect(fixture.store.errorMessage == nil)
+ #expect(fixture.store.searchHubs.isEmpty)
+ status.withLock { $0 = 200 }
+ fixture.store.submitSearch()
+ try await waitFor { !fixture.store.isSearching }
+ #expect(fixture.store.searchErrorMessage == nil)
+ #expect(fixture.store.searchHubs.first?.key == "/hubs/all?query=star%20wars&type=1")
+ #expect(fixture.requests.withLock { $0.filter { $0.url?.path == "/provider/search" }.count } == 2)
+ fixture.store.searchQuery = ""
+ fixture.store.submitSearch()
+ #expect(fixture.store.searchHubs.isEmpty)
+ }
+
+ private func waitFor(_ condition: () -> Bool) async throws {
+ let deadline = ContinuousClock.now.advanced(by: .seconds(5))
+ while !condition() {
+ try #require(ContinuousClock.now < deadline, "Playback operation did not complete within five seconds.")
+ try await Task.sleep(for: .milliseconds(10))
+ }
+ }
+
+ private static func navigationQueue() throws -> PlexPlaybackQueue {
+ try PlexPlaybackQueue(page: JSONDecoder().decode(
+ PlexPlayQueueEnvelope.self, from: Data(navigationQueueJSON.utf8)
+ ).mediaContainer.page(), selectedRatingKey: "42")
+ }
+
+ private static let navigationQueueJSON = #"{"MediaContainer":{"playQueueID":9,"playQueueVersion":1,"playQueueTotalCount":3,"playQueueSelectedItemID":502,"playQueueSelectedItemOffset":1,"offset":0,"Metadata":[{"ratingKey":"41","title":"Previous episode","type":"episode","playQueueItemID":"501"},{"ratingKey":"42","title":"Current episode","type":"episode","playQueueItemID":"502"},{"ratingKey":"43","title":"Next episode","type":"episode","playQueueItemID":"503"}]}}"#
+
+ private static func item(_ json: String) throws -> PlexMediaItem {
+ try JSONDecoder().decode(PlexMediaItem.self, from: Data(json.utf8))
+ }
+
+ private static func hierarchyJSON(type: String) -> String {
+ #"{"ratingKey":"7","key":"/library/metadata/7/children","title":"Series","type":"\#(type)","Media":[]}"#
+ }
+
+ private static let episodeJSON = #"{"ratingKey":"42","key":"/library/metadata/42","title":"Selected episode","type":"episode","viewOffset":123000,"duration":1800000,"Media":[{"id":1,"Part":[{"id":2,"key":"/library/parts/2/file.mp4"}]}]}"#
+
+ private final class TVPendingAssetLoader: NSObject, AVAssetResourceLoaderDelegate {
+ nonisolated func resourceLoader(
+ _ resourceLoader: AVAssetResourceLoader,
+ shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest
+ ) -> Bool {
+ true
+ }
+ }
+
+ @MainActor
+ private final class Fixture {
+ let store: TVAppStore
+ let requests = OSAllocatedUnfairLock(initialState: [URLRequest]())
+ let homeRequests: AsyncStream
+ let timelineRequests: AsyncStream
+ private let pendingAssetLoader = TVPendingAssetLoader()
+ private let session: URLSession
+ private let defaults: UserDefaults
+ private let defaultsName = "TVPlaybackPreparationTests.\(UUID())"
+ private let topShelfPublisher: TVTopShelfPublisher
+
+ init(
+ hierarchyType: String = "show",
+ selectedEpisodeStatus: Int = 200,
+ selectedSeasonStatus: Int = 200,
+ timelineGate: TVTimelineResponseGate? = nil,
+ libraryGate: TVTimelineResponseGate? = nil,
+ libraryStatus: Int = 200,
+ neighborMetadataStatus: OSAllocatedUnfairLock? = nil,
+ episodeMetadata: String? = nil,
+ subtitleOffsetStatus: OSAllocatedUnfairLock? = nil,
+ hubStatus: OSAllocatedUnfairLock? = nil,
+ failingHubOffset: Int = 0,
+ hubGate: TVTimelineResponseGate? = nil,
+ searchStatus: OSAllocatedUnfairLock? = nil,
+ hubIncludesAudio: Bool = false
+ ) async throws {
+ let homeEvents = AsyncStream.makeStream()
+ let timelineEvents = AsyncStream.makeStream()
+ homeRequests = homeEvents.stream
+ timelineRequests = timelineEvents.stream
+ defaults = try #require(UserDefaults(suiteName: defaultsName))
+ let requests = requests
+ let hierarchy = TVPlaybackPreparationTests.hierarchyJSON(type: hierarchyType)
+ let episode = episodeMetadata ?? TVPlaybackPreparationTests.episodeJSON
+ let navigationQueue = TVPlaybackPreparationTests.navigationQueueJSON
+ TVPlaybackMockProtocol.handler.withLock { handler in
+ handler = { request in
+ requests.withLock { $0.append(request) }
+ let json: String
+ switch request.url?.path {
+ case "/identity":
+ json = #"{"MediaContainer":{"machineIdentifier":"test-server","friendlyName":"Test Server"}}"#
+ case "/hubs/promoted":
+ homeEvents.continuation.yield(())
+ json = #"{"MediaContainer":{"Hub":[]}}"#
+ case "/hubs/continueWatching":
+ json = #"{"MediaContainer":{"Hub":[]}}"#
+ case "/library/sections/all":
+ json = #"{"MediaContainer":{"Directory":[]}}"#
+ case "/library/sections/1/filters":
+ json = #"{"MediaContainer":{"Directory":[{"filter":"genre","title":"Genre","filterType":"string","key":"/library/sections/1/genre"},{"filter":"unwatched","title":"Unplayed","filterType":"boolean"}]}}"#
+ case "/library/sections/1/sorts":
+ json = #"{"MediaContainer":{"Directory":[{"key":"titleSort","title":"Title","descKey":"titleSort:desc"}]}}"#
+ case "/library/sections/1/genre":
+ json = #"{"MediaContainer":{"Directory":[{"key":"10","title":"Comedy"},{"key":"20","title":"Drama"}]}}"#
+ case "/provider/search":
+ json = #"{"MediaContainer":{"Hub":[{"hubIdentifier":"search.movies","title":"Movies","key":"/hubs/all?query=star%20wars&type=1","more":true,"totalSize":5,"Metadata":[{"ratingKey":"a","title":"Star Wars","type":"movie"}]}]}}"#
+ case "/hubs/all", "/hubs/other":
+ let offset = Int(request.value(forHTTPHeaderField: "X-Plex-Container-Start") ?? "0") ?? 0
+ if request.url?.path == "/hubs/all", offset == 0, let hubGate { await hubGate.blockResponse() }
+ let keys = request.url?.path == "/hubs/other" ? ["new"] : offset == 0 ? ["a", "b"] : offset == 2 ? ["b", "c"] : ["d"]
+ let payload: [String: Any] = ["MediaContainer": ["offset": offset, "totalSize": request.url?.path == "/hubs/other" ? 1 : 5,
+ "Metadata": keys.map { ["ratingKey": $0, "title": $0, "type": hubIncludesAudio && $0 == "b" ? "album" : "movie"] }]]
+ json = String(decoding: try JSONSerialization.data(withJSONObject: payload), as: UTF8.self)
+ case "/library/sections/1/all":
+ let query = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems ?? []
+ let sorted = query.contains { $0.name == "sort" }
+ if !sorted, let libraryGate { await libraryGate.blockResponse() }
+ let offset = Int(request.value(forHTTPHeaderField: "X-Plex-Container-Start") ?? "0") ?? 0
+ let key = offset == 2 ? "last" : sorted ? "sorted" : "default"
+ json = #"{"MediaContainer":{"offset":\#(offset),"totalSize":3,"Metadata":[{"ratingKey":"\#(key)","title":"Item","type":"movie"}]}}"#
+ case "/library/metadata/7":
+ json = #"{"MediaContainer":{"Metadata":[\#(hierarchy)]}}"#
+ case "/library/metadata/41", "/library/metadata/43":
+ let key = request.url!.lastPathComponent
+ json = #"{"MediaContainer":{"Metadata":[\#(episode.replacingOccurrences(of: "42", with: key))]}}"#
+ case "/library/streams/9":
+ json = #"{"MediaContainer":{}}"#
+ case "/provider/queue/9/reset":
+ json = navigationQueue
+ .replacingOccurrences(of: "\"playQueueSelectedItemID\":502", with: "\"playQueueSelectedItemID\":501")
+ .replacingOccurrences(of: "\"playQueueSelectedItemOffset\":1", with: "\"playQueueSelectedItemOffset\":0")
+ case "/library/metadata/42":
+ json = #"{"MediaContainer":{"Metadata":[\#(episode)]}}"#
+ case "/library/metadata/7/children":
+ json = #"{"MediaContainer":{"Metadata":[{"ratingKey":"72","type":"season","title":"Season 2","index":2},{"ratingKey":"70","type":"season","title":"Season 1","index":1}]}}"#
+ case "/library/metadata/72/children":
+ json = #"{"MediaContainer":{"Metadata":[\#(episode)]}}"#
+ case "/media/providers":
+ json = #"{"MediaContainer":{"MediaProvider":[{"identifier":"com.plexapp.plugins.library","Feature":[{"type":"promoted","key":"/hubs/promoted"},{"type":"continuewatching","key":"/hubs/continueWatching"},{"type":"search","key":"/provider/search"},{"type":"playqueue","key":"/provider/queue"},{"type":"timeline","key":"/provider/timeline"}]}]}}"#
+ case "/provider/timeline":
+ timelineEvents.continuation.yield(request)
+ if let timelineGate { await timelineGate.blockResponse() }
+ json = #"{"MediaContainer":{}}"#
+ case "/video/:/transcode/universal/decision":
+ json = #"{"MediaContainer":{"generalDecisionCode":1000,"Metadata":[{"ratingKey":"42","title":"Episode","Media":[{"Part":[{"decision":"directplay","key":"/library/parts/2/file.mp4"}]}]}]}}"#
+ case "/provider/queue":
+ json = #"{"MediaContainer":{"playQueueID":9,"playQueueVersion":1,"playQueueTotalCount":2,"playQueueSelectedItemID":502,"playQueueSelectedItemOffset":1,"offset":0,"Metadata":[{"ratingKey":"41","title":"Earlier episode","type":"episode","playQueueItemID":"501"},{"ratingKey":"42","title":"Selected episode","type":"episode","playQueueItemID":"502"}]}}"#
+ default:
+ throw URLError(.unsupportedURL)
+ }
+ let statusCode = switch request.url?.path {
+ case "/provider/search": searchStatus?.withLock { $0 } ?? 200
+ case "/hubs/all": Int(request.value(forHTTPHeaderField: "X-Plex-Container-Start") ?? "0") == failingHubOffset ? hubStatus?.withLock { $0 } ?? 200 : 200
+ case "/library/streams/9": subtitleOffsetStatus?.withLock { $0 } ?? 200
+ case "/library/metadata/42": selectedEpisodeStatus
+ case "/library/metadata/41", "/library/metadata/43": neighborMetadataStatus?.withLock { $0 } ?? 200
+ case "/library/metadata/72/children": selectedSeasonStatus
+ case "/library/sections/1/all": libraryStatus
+ default: 200
+ }
+ let response = HTTPURLResponse(url: request.url!, statusCode: statusCode, httpVersion: nil, headerFields: ["Content-Type": "application/json"])!
+ return (response, Data(json.utf8))
+ }
+ }
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.protocolClasses = [TVPlaybackMockProtocol.self]
+ session = URLSession(configuration: configuration)
+ let topShelfCache = TVTopShelfCache(
+ directory: FileManager.default.temporaryDirectory.appending(path: defaultsName)
+ )
+ topShelfPublisher = TVTopShelfPublisher(cache: { topShelfCache }, notify: {})
+ store = TVAppStore(
+ client: TVPlexClient(session: session),
+ defaults: defaults,
+ keychain: KeychainStore(service: defaultsName),
+ topShelfPublisher: topShelfPublisher
+ )
+ await store.selectServer(PlexServerResource(
+ id: "test-server", name: "Test Server", productVersion: nil, accessToken: "test-token",
+ connections: [PlexServerConnection(uri: URL(string: "https://plex.test")!, local: true, relay: false)]
+ ))
+ #expect(store.isConnected)
+ }
+
+ func makePlaybackSession() -> TVPlaybackSession {
+ let loader = pendingAssetLoader
+ return TVPlaybackSession { _ in
+ // Keep native AVPlayer in the loading state deterministically while
+ // exercising resume and queue orchestration, without external DNS.
+ let asset = AVURLAsset(url: URL(string: "plexbar-test://pending/media.mp4")!)
+ asset.resourceLoader.setDelegate(loader, queue: .main)
+ return AVPlayerItem(asset: asset)
+ }
+ }
+
+ func close() {
+ topShelfPublisher.clear()
+ session.invalidateAndCancel()
+ defaults.removePersistentDomain(forName: defaultsName)
+ }
+
+ func waitForRefreshCompletion() async {
+ guard store.isLoadingHome || store.isLoadingLibraries else { return }
+ await withCheckedContinuation { continuation in
+ withObservationTracking {
+ _ = store.isLoadingHome
+ _ = store.isLoadingLibraries
+ } onChange: {
+ continuation.resume()
+ }
+ }
+ }
+ }
+}
+
+private final class TVPlaybackMockProtocol: URLProtocol, @unchecked Sendable {
+ typealias Handler = @Sendable (URLRequest) async throws -> (HTTPURLResponse, Data)
+ static let handler = Mutex(nil)
+ private let loadingTask = OSAllocatedUnfairLock?>(initialState: nil)
+
+ override class func canInit(with request: URLRequest) -> Bool { true }
+ override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
+ override func startLoading() {
+ guard let handler = Self.handler.withLock({ $0 }) else {
+ client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
+ return
+ }
+ let task = Task {
+ do {
+ let (response, data) = try await handler(request)
+ guard !Task.isCancelled else { return }
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: data)
+ client?.urlProtocolDidFinishLoading(self)
+ } catch {
+ client?.urlProtocol(self, didFailWithError: error)
+ }
+ }
+ loadingTask.withLock { $0 = task }
+ }
+ override func stopLoading() {
+ loadingTask.withLock { $0?.cancel() }
+ }
+}
+
+private actor TVTimelineResponseGate {
+ private var hasEntered = false
+ private var entryWaiter: CheckedContinuation?
+ private var releaseWaiter: CheckedContinuation?
+
+ func blockResponse() async {
+ await withCheckedContinuation { continuation in
+ releaseWaiter = continuation
+ hasEntered = true
+ entryWaiter?.resume()
+ entryWaiter = nil
+ }
+ }
+
+ func waitUntilEntered() async {
+ guard !hasEntered else { return }
+ await withCheckedContinuation { entryWaiter = $0 }
+ }
+
+ func release() {
+ releaseWaiter?.resume()
+ releaseWaiter = nil
+ }
+}
+
+#endif
diff --git a/PlexBarTests/TV/TVTopShelfTests.swift b/PlexBarTests/TV/TVTopShelfTests.swift
new file mode 100644
index 0000000..3209e78
--- /dev/null
+++ b/PlexBarTests/TV/TVTopShelfTests.swift
@@ -0,0 +1,368 @@
+import PlexModels
+#if os(tvOS)
+import Foundation
+import Synchronization
+import Testing
+import TVServices
+import UIKit
+@testable import PlexBarTV
+
+@MainActor
+@Suite(.serialized, .timeLimit(.minutes(1)))
+struct TVTopShelfTests {
+ @Test(arguments: [TVTopShelfRoute.Action.display, .play])
+ func routesRoundTripWithoutCredentials(action: TVTopShelfRoute.Action) throws {
+ let route = TVTopShelfRoute(action: action, serverIdentifier: "server &+=?雪", ratingKey: "42")
+ #expect(TVTopShelfRoute(url: route.url) == route)
+ #expect(route.url.host == "topshelf")
+ #expect(!route.url.absoluteString.contains("X-Plex-Token"))
+ }
+
+ @Test(arguments: [
+ "https://topshelf/play?server=s&item=42",
+ "plexbar-tv://other/play?server=s&item=42",
+ "plexbar-tv://topshelf/delete?server=s&item=42",
+ "plexbar-tv://topshelf/play?server=s&item=../42",
+ "plexbar-tv://topshelf/play?server=s&item=42%2Fchildren",
+ "plexbar-tv://topshelf/play?server=s&item=-1",
+ "plexbar-tv://topshelf/play?server=s&item=",
+ "plexbar-tv://topshelf/play?server=&item=42",
+ "plexbar-tv://topshelf/play?server=s&item=42&item=43",
+ "plexbar-tv://topshelf/play?server=s&item=42&token=secret",
+ "plexbar-tv://user@topshelf/play?server=s&item=42",
+ "plexbar-tv://topshelf:80/play?server=s&item=42",
+ "plexbar-tv://topshelf/play?server=s&item=42#fragment"
+ ])
+ func malformedRoutesAreRejected(value: String) throws {
+ #expect(TVTopShelfRoute(url: try #require(URL(string: value))) == nil)
+ }
+
+ @Test func selectionUsesDocumentedHubsInPriorityOrderAndDeduplicates() throws {
+ let episode = try item(1, type: "episode")
+ let hubs = try [
+ hub("home.movies.recent", title: "Films récents", items: [episode, item(2)]),
+ hub("home.random", title: "Recently Added", items: [item(3)]),
+ hub("continueWatching", title: "Continuer", items: [episode]),
+ hub("home.television.recent", items: [item(4, type: "show")]),
+ hub("home.music.recent", items: [item(5, type: "album")])
+ ]
+ let selection = TVTopShelfSelection(hubs: hubs)
+ #expect(selection.sections.map(\.identifier) == [
+ "continueWatching", "home.movies.recent", "home.television.recent", "home.music.recent"
+ ])
+ #expect(selection.sections.map(\.title).prefix(2) == ["Continuer", "Films récents"])
+ #expect(selection.sections.flatMap(\.items).map(\.ratingKey) == ["1", "2", "4", "5"])
+ #expect(TVTopShelfSelection.artworkPath(for: episode) == "/series/poster")
+ #expect(TVTopShelfSelection.title(for: episode) == "Series — S1 • E2 - Title 1")
+ }
+
+ @Test func selectionIsBoundedAndOmitsUnsupportedOrMissingArtwork() throws {
+ let items = try (1...15).map { try item($0) }
+ let selection = TVTopShelfSelection(hubs: [
+ try hub("continueWatching", items: items),
+ try hub("home.movies.recent", items: [items[10], item(16, type: "photo"), item(17, artwork: false)])
+ ])
+ #expect(selection.sections[0].items.count == 10)
+ #expect(selection.sections[1].items.map(\.ratingKey) == ["11"])
+ }
+
+ @Test func promotedLibraryRecentHubsKeepServerOrderAndExcludeRecentlyReleased() throws {
+ let selection = TVTopShelfSelection(hubs: [
+ try hub("tv.recentlyadded.2", title: "Recently Added TV", items: [item(1, type: "show")]),
+ try hub("movie.recentlyreleased.1", items: [item(2)]),
+ try hub("movie.recentlyadded.1", title: "Recently Added Movies", items: [item(3)]),
+ try hub("music.recent.added.3", items: [item(4, type: "album")]),
+ try hub("movie.recentlyadded.invalid", items: [item(5)]),
+ try hub("movie.recentlyadded.4", items: [item(6)])
+ ])
+ #expect(selection.sections.map(\.identifier) == ["tv.recentlyadded.2", "movie.recentlyadded.1", "music.recent.added.3", "movie.recentlyadded.4"])
+ #expect(selection.sections.flatMap(\.items).map(\.ratingKey) == ["1", "3", "4", "6"])
+ }
+
+ @Test func nativeContentHasArtworkForBothScalesProgressAndCorrectActions() throws {
+ let cache = temporaryCache()
+ defer { try? cache.clear() }
+ let filename = try cache.storeImage(jpeg())
+ let snapshot = snapshot(filename: filename, progress: 0.4)
+ try cache.write(snapshot)
+ #expect(try cache.read() == snapshot)
+ let content = try #require(TVTopShelfContentBuilder.make(snapshot: snapshot, cache: cache))
+ #expect(content.sections.count == 1)
+ let entry = try #require(content.sections.first?.items.first)
+ #expect(entry.title == "Continue this episode")
+ #expect(entry.imageShape == .poster)
+ #expect(entry.playbackProgress == 0.4)
+ #expect(entry.imageURL(for: .screenScale1x) == cache.imageURL(filename: filename))
+ #expect(entry.imageURL(for: .screenScale2x) == cache.imageURL(filename: filename))
+ #expect(TVTopShelfRoute(url: try #require(entry.displayAction?.url))?.action == .display)
+ #expect(TVTopShelfRoute(url: try #require(entry.playAction?.url))?.action == .play)
+ #expect(TVTopShelfRoute(url: try #require(entry.playAction?.url))?.serverIdentifier == "server")
+ }
+
+ @Test func cacheClearAndPurgedArtworkProduceNoDynamicContent() throws {
+ let cache = temporaryCache()
+ defer { try? cache.clear() }
+ let filename = try cache.storeImage(jpeg())
+ let snapshot = snapshot(filename: filename)
+ try cache.write(snapshot)
+ try FileManager.default.removeItem(at: #require(cache.imageURL(filename: filename)))
+ #expect(TVTopShelfContentBuilder.make(snapshot: snapshot, cache: cache) == nil)
+ try cache.clear()
+ #expect(try cache.read() == nil)
+ #expect(!FileManager.default.fileExists(atPath: cache.directory.path))
+ #expect(cache.imageURL(filename: "../../secret.jpg") == nil)
+ #expect(cache.imageURL(filename: "https://plex.test/image?X-Plex-Token=secret") == nil)
+ }
+
+ @Test func cacheRejectsFutureSchemaAndNativeProgressIsClamped() throws {
+ let cache = temporaryCache()
+ defer { try? cache.clear() }
+ let filename = try cache.storeImage(jpeg())
+ let highProgress = snapshot(filename: filename, progress: 2)
+ let content = try #require(TVTopShelfContentBuilder.make(snapshot: highProgress, cache: cache))
+ #expect(content.sections[0].items[0].playbackProgress == 1)
+ var future = highProgress
+ future.version += 1
+ try cache.write(future)
+ #expect(throws: TVTopShelfCache.CacheError.self) { try cache.read() }
+ #expect(TVTopShelfContentBuilder.make(snapshot: future, cache: cache) == nil)
+ }
+
+ @Test func artworkRetentionStartsAtItsLastPublication() throws {
+ let cache = temporaryCache()
+ defer { try? cache.clear() }
+ let data = jpeg()
+ let filename = try cache.storeImage(data)
+ let url = try #require(cache.imageURL(filename: filename))
+ try FileManager.default.setAttributes([.modificationDate: Date.now.addingTimeInterval(-172_800)], ofItemAtPath: url.path)
+ #expect(try cache.storeImage(data) == filename)
+ let empty = TVTopShelfSnapshot(serverIdentifier: "server", sections: [])
+ try cache.pruneImages(keeping: empty)
+ #expect(FileManager.default.fileExists(atPath: url.path))
+ try cache.pruneImages(keeping: empty, now: .now.addingTimeInterval(172_800))
+ #expect(!FileManager.default.fileExists(atPath: url.path))
+ }
+
+ @Test func publisherWritesLocalArtworkAndReplacesPopulatedContentWithEmptyFeed() async throws {
+ let cache = temporaryCache()
+ defer { try? cache.clear() }
+ let image = jpeg()
+ let (session, client) = mockClient { request in
+ #expect(request.timeoutInterval == 10)
+ #expect(request.value(forHTTPHeaderField: "X-Plex-Token") == "private-token")
+ return (200, image)
+ }
+ defer { session.invalidateAndCancel() }
+ var notifications = 0
+ let publisher = TVTopShelfPublisher(cache: { cache }, notify: { notifications += 1 })
+ publisher.publish(hubs: [try hub("continueWatching", items: [item(42, type: "episode")])], connection: connection(), client: client)
+ await publisher.waitForPublication()
+ let published = try #require(try cache.read())
+ #expect(published.sections.first?.items.first?.ratingKey == "42")
+ let json = String(decoding: try JSONEncoder().encode(published), as: UTF8.self)
+ #expect(!json.contains("private-token"))
+ #expect(!json.contains("https://"))
+ #expect(notifications == 1)
+ publisher.publish(hubs: [], connection: connection(), client: client)
+ await publisher.waitForPublication()
+ #expect(try cache.read()?.sections.isEmpty == true)
+ #expect(notifications == 2)
+ }
+
+ @Test func publicationCannotRestoreContentAfterClearWhileArtworkIsLoading() async throws {
+ let cache = temporaryCache()
+ defer { try? cache.clear() }
+ let started = AsyncStream.makeStream()
+ let (session, client) = mockClient { _ in
+ started.continuation.yield(())
+ try await Task.sleep(for: .seconds(30))
+ return (200, Data())
+ }
+ defer { session.invalidateAndCancel() }
+ let publisher = TVTopShelfPublisher(cache: { cache }, notify: {})
+ publisher.publish(hubs: [try hub("continueWatching", items: [item(42)])], connection: connection(), client: client)
+ let publication = Task { await publisher.waitForPublication() }
+ var events = started.stream.makeAsyncIterator()
+ _ = await events.next()
+ publisher.clear()
+ await publication.value
+ #expect(try cache.read() == nil)
+ #expect(!FileManager.default.fileExists(atPath: cache.directory.path))
+ }
+
+ @Test func brokenArtworkIsOmittedWithoutAlternateRequests() async throws {
+ let cache = temporaryCache()
+ defer { try? cache.clear() }
+ let requests = Mutex(0)
+ let (session, client) = mockClient { _ in
+ requests.withLock { $0 += 1 }
+ return (200, Data("not an image".utf8))
+ }
+ defer { session.invalidateAndCancel() }
+ let publisher = TVTopShelfPublisher(cache: { cache }, notify: {})
+ publisher.publish(hubs: [try hub("continueWatching", items: [item(42)])], connection: connection(), client: client)
+ await publisher.waitForPublication()
+ #expect(try cache.read()?.sections.isEmpty == true)
+ #expect(requests.withLock { $0 } == 1)
+ }
+
+ @Test(arguments: [TVTopShelfRoute.Action.display, .play])
+ func coldLaunchRouteWaitsForSessionAndFetchesFreshMetadata(action: TVTopShelfRoute.Action) async throws {
+ let fixture = try routingFixture()
+ defer { fixture.close() }
+ fixture.store.openTopShelfURL(TVTopShelfRoute(action: action, serverIdentifier: "server", ratingKey: "42").url)
+ #expect(fixture.store.homePath.isEmpty)
+ await fixture.connect()
+ #expect(fixture.store.homePath.isEmpty)
+ await fixture.store.restoreSession()
+ try await waitFor { !fixture.store.homePath.isEmpty }
+ let destination = try #require(fixture.store.homePath.first)
+ guard case .media(let item) = destination else {
+ Issue.record("Top Shelf must open a media destination.")
+ return
+ }
+ #expect(item.ratingKey == "42")
+ if action == .play {
+ try await waitFor { fixture.store.playbackRequest != nil }
+ #expect(fixture.store.playbackRequest?.startTime == 123)
+ } else {
+ #expect(fixture.store.playbackRequest == nil)
+ }
+ }
+
+ @Test func routeToAnotherServerDoesNotFetchOrPlayAnUnrelatedTitle() async throws {
+ let fixture = try routingFixture()
+ defer { fixture.close() }
+ await fixture.connect()
+ await fixture.store.restoreSession()
+ fixture.store.openTopShelfURL(TVTopShelfRoute(action: .play, serverIdentifier: "another-server", ratingKey: "42").url)
+ #expect(fixture.store.errorMessage?.contains("different Plex server") == true)
+ #expect(fixture.store.homePath.isEmpty)
+ #expect(fixture.store.playbackRequest == nil)
+ }
+
+ private func item(_ key: Int, type: String = "movie", artwork: Bool = true) throws -> PlexMediaItem {
+ var value: [String: Any] = ["ratingKey": String(key), "title": "Title \(key)", "type": type,
+ "grandparentTitle": "Series", "parentIndex": 1, "index": 2]
+ if artwork { value["thumb"] = "/poster/\(key)"; value["grandparentThumb"] = "/series/poster" }
+ return try JSONDecoder().decode(PlexMediaItem.self, from: JSONSerialization.data(withJSONObject: value))
+ }
+
+ private func hub(_ identifier: String, title: String = "Recently Added", items: [PlexMediaItem]) throws -> PlexHub {
+ var hub = try JSONDecoder().decode(PlexHub.self, from: JSONSerialization.data(withJSONObject: [
+ "hubIdentifier": identifier, "title": title
+ ]))
+ hub.metadata = items
+ return hub
+ }
+
+ private func temporaryCache() -> TVTopShelfCache {
+ TVTopShelfCache(directory: FileManager.default.temporaryDirectory.appending(path: "TopShelfTests-\(UUID())"))
+ }
+
+ private func jpeg() -> Data {
+ UIGraphicsImageRenderer(size: CGSize(width: 2, height: 3)).jpegData(withCompressionQuality: 0.8) { context in
+ UIColor.red.setFill()
+ context.fill(CGRect(x: 0, y: 0, width: 2, height: 3))
+ }
+ }
+
+ private func snapshot(filename: String, progress: Double = 0) -> TVTopShelfSnapshot {
+ .init(serverIdentifier: "server", sections: [.init(identifier: "continueWatching", title: "Continue Watching", items: [
+ .init(ratingKey: "42", title: "Continue this episode", imageFilename: filename, shape: .poster, playbackProgress: progress, canPlay: true)
+ ])])
+ }
+
+ private func connection() -> TVPlexConnection {
+ .init(serverURL: URL(string: "https://plex.test")!, token: "private-token", clientIdentifier: "client", serverIdentifier: "server", kind: .local)
+ }
+
+ private func mockClient(handler: @escaping TopShelfMockProtocol.Handler) -> (URLSession, TVPlexClient) {
+ TopShelfMockProtocol.handler.withLock { $0 = handler }
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.protocolClasses = [TopShelfMockProtocol.self]
+ let session = URLSession(configuration: configuration)
+ return (session, TVPlexClient(session: session))
+ }
+
+ private func waitFor(_ predicate: () -> Bool) async throws {
+ let deadline = ContinuousClock.now.advanced(by: .seconds(5))
+ while !predicate() {
+ try #require(ContinuousClock.now < deadline)
+ try await Task.sleep(for: .milliseconds(10))
+ }
+ }
+
+ private func routingFixture() throws -> RoutingFixture {
+ let (session, client) = mockClient { request in
+ let json: String
+ switch request.url?.path {
+ case "/identity": json = #"{"MediaContainer":{"machineIdentifier":"server"}}"#
+ case "/media/providers":
+ json = #"{"MediaContainer":{"MediaProvider":[{"identifier":"com.plexapp.plugins.library","Feature":[{"type":"promoted","key":"/hubs/promoted"},{"type":"continuewatching","key":"/hubs/continueWatching"}]}]}}"#
+ case "/hubs/continueWatching": json = #"{"MediaContainer":{"Hub":[]}}"#
+ case "/hubs/promoted": json = #"{"MediaContainer":{"Hub":[]}}"#
+ case "/library/sections/all": json = #"{"MediaContainer":{"Directory":[]}}"#
+ case "/library/metadata/42":
+ json = #"{"MediaContainer":{"Metadata":[{"ratingKey":"42","type":"movie","title":"Fresh title","viewOffset":123000,"duration":300000,"Media":[{"Part":[{"key":"/file.mp4"}]}]}]}}"#
+ default: return (404, Data())
+ }
+ return (200, Data(json.utf8))
+ }
+ let cache = temporaryCache()
+ let suite = "TopShelfRoutingTests-\(UUID())"
+ let defaults = try #require(UserDefaults(suiteName: suite))
+ let publisher = TVTopShelfPublisher(cache: { cache }, notify: {})
+ let store = TVAppStore(client: client, defaults: defaults, keychain: KeychainStore(service: suite), topShelfPublisher: publisher)
+ return RoutingFixture(store: store, session: session, defaults: defaults, suite: suite, cache: cache, publisher: publisher)
+ }
+
+ @MainActor
+ private struct RoutingFixture {
+ let store: TVAppStore
+ let session: URLSession
+ let defaults: UserDefaults
+ let suite: String
+ let cache: TVTopShelfCache
+ let publisher: TVTopShelfPublisher
+
+ func connect() async {
+ await store.selectServer(.init(id: "server", name: "Server", productVersion: nil, accessToken: "private-token", connections: [
+ .init(uri: URL(string: "https://plex.test")!, local: true, relay: false)
+ ]))
+ }
+
+ func close() {
+ publisher.clear()
+ session.invalidateAndCancel()
+ defaults.removePersistentDomain(forName: suite)
+ }
+ }
+}
+
+private final class TopShelfMockProtocol: URLProtocol, @unchecked Sendable {
+ typealias Handler = @Sendable (URLRequest) async throws -> (Int, Data)
+ static let handler = Mutex(nil)
+ private let loadingTask = Mutex?>(nil)
+
+ override class func canInit(with request: URLRequest) -> Bool { true }
+ override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
+ override func startLoading() {
+ guard let handler = Self.handler.withLock({ $0 }) else { return }
+ let loading = Task {
+ do {
+ let (status, data) = try await handler(request)
+ try Task.checkCancellation()
+ let response = HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)!
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: data)
+ client?.urlProtocolDidFinishLoading(self)
+ } catch {
+ client?.urlProtocol(self, didFailWithError: error)
+ }
+ }
+ loadingTask.withLock { $0 = loading }
+ }
+ override func stopLoading() { loadingTask.withLock { $0?.cancel() } }
+}
+#endif
diff --git a/PlexBarTests/TVUI/TVRemoteNavigationTests.swift b/PlexBarTests/TVUI/TVRemoteNavigationTests.swift
new file mode 100644
index 0000000..9ab702d
--- /dev/null
+++ b/PlexBarTests/TVUI/TVRemoteNavigationTests.swift
@@ -0,0 +1,547 @@
+#if os(tvOS)
+import XCTest
+
+/// Runs against the configured Plex server. Keep this in the separate live UI scheme.
+@MainActor
+final class TVRemoteNavigationTests: XCTestCase {
+ func testLibrarySortAndFilterMenusUseServerChoices() throws {
+ continueAfterFailure = false
+ let app = XCUIApplication()
+ app.launch()
+ let libraries = app.tabBars.buttons["Libraries"]
+ guard libraries.waitForExistence(timeout: 20) else { throw XCTSkip("Requires a signed-in server.") }
+ for _ in 0..<5 {
+ if libraries.hasFocus { break }
+ XCUIRemote.shared.press(.right)
+ }
+ XCTAssertTrue(libraries.hasFocus)
+ XCUIRemote.shared.press(.select)
+ XCUIRemote.shared.press(.down)
+ capture(app, name: "Libraries")
+ XCUIRemote.shared.press(.select)
+ let sort = app.buttons["library-sort"]
+ XCTAssertTrue(sort.waitForExistence(timeout: 15))
+ let ready = expectation(for: NSPredicate { _, _ in sort.isEnabled }, evaluatedWith: sort)
+ XCTAssertEqual(XCTWaiter.wait(for: [ready], timeout: 15), .completed)
+ let movies = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'media.movie.'"))
+ XCTAssertTrue(movies.firstMatch.waitForExistence(timeout: 15))
+ let originalFirstMovie = movies.firstMatch.identifier
+ capture(app, name: "Library grid and controls")
+ XCUIRemote.shared.press(.down)
+ for _ in 0..<6 { XCUIRemote.shared.press(.right) }
+ for _ in 0..<5 {
+ if containsFocus(sort) { break }
+ XCUIRemote.shared.press(.up)
+ }
+ for _ in 0..<4 {
+ if containsFocus(sort) { break }
+ XCUIRemote.shared.press(.left)
+ }
+ capture(app, name: "Library sort focus")
+ XCTAssertTrue(containsFocus(sort))
+ XCUIRemote.shared.press(.select)
+ XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 5))
+ capture(app, name: "Server sort choices")
+ try selectMenuChoice("Release Date", in: app)
+ XCTAssertTrue(app.cells.firstMatch.waitForNonExistence(timeout: 5))
+ let sorted = expectation(for: NSPredicate { _, _ in
+ movies.firstMatch.exists && movies.firstMatch.identifier != originalFirstMovie
+ }, evaluatedWith: app)
+ XCTAssertEqual(XCTWaiter.wait(for: [sorted], timeout: 15), .completed)
+ XCTAssertEqual(sort.value as? String, "Release Date")
+ capture(app, name: "Sorted library")
+ let unfilteredFirstMovie = movies.firstMatch.identifier
+ XCUIRemote.shared.press(.right)
+ let filter = app.buttons["library-filter"]
+ XCTAssertTrue(containsFocus(filter))
+ XCUIRemote.shared.press(.select)
+ XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 5))
+ capture(app, name: "Server filter choices")
+ try selectMenuChoice("Genre", in: app)
+ XCTAssertTrue(app.buttons["Apply"].waitForExistence(timeout: 10))
+ capture(app, name: "Genre filter values")
+ let actionGenre = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'filter-value.' AND label == 'Action'")).firstMatch
+ XCTAssertTrue(actionGenre.waitForExistence(timeout: 10))
+ XCUIRemote.shared.press(.down)
+ XCTAssertTrue(containsFocus(app.cells.containing(.button, identifier: actionGenre.identifier).firstMatch))
+ XCUIRemote.shared.press(.select)
+ XCTAssertEqual(actionGenre.value as? String, "Selected")
+ let apply = app.buttons["Apply (1)"]
+ for _ in 0..<40 {
+ if containsFocus(app.buttons["Clear"]) || containsFocus(app.buttons["Cancel"]) || containsFocus(apply) { break }
+ XCUIRemote.shared.press(.down)
+ }
+ for _ in 0..<3 {
+ if containsFocus(apply) { break }
+ XCUIRemote.shared.press(.right)
+ }
+ XCTAssertTrue(containsFocus(apply))
+ capture(app, name: "Selected genre ready to apply")
+ XCUIRemote.shared.press(.select)
+ XCTAssertTrue(apply.waitForNonExistence(timeout: 5))
+ let filtered = expectation(for: NSPredicate { _, _ in
+ movies.firstMatch.exists && movies.firstMatch.identifier != unfilteredFirstMovie
+ }, evaluatedWith: app)
+ XCTAssertEqual(XCTWaiter.wait(for: [filtered], timeout: 15), .completed)
+ capture(app, name: "Filtered library")
+ XCUIRemote.shared.press(.menu)
+ }
+
+ func testHomeRemoteMovesBetweenMediaWithoutHeadingStops() throws {
+ continueAfterFailure = false
+ let app = XCUIApplication()
+ app.launch()
+ let cards = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'media.'"))
+ guard cards.firstMatch.waitForExistence(timeout: 20) else { throw XCTSkip("Requires signed-in Home media.") }
+ XCTAssertEqual(app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'hub-show-all.'")).count, 0)
+ XCTAssertFalse(app.buttons["Show All"].exists)
+ XCTAssertFalse(app.buttons["Load More"].exists)
+ XCTAssertEqual(cards.matching(NSPredicate(format:
+ "identifier BEGINSWITH 'media.album.' OR identifier BEGINSWITH 'media.artist.' OR identifier BEGINSWITH 'media.track.'"
+ )).count, 0, "Apple TV Home should promote movies and TV, not audio libraries.")
+ XCUIRemote.shared.press(.down)
+ let first = try XCTUnwrap(cards.allElementsBoundByIndex.first(where: containsFocus))
+ XCUIRemote.shared.press(.right)
+ let second = try XCTUnwrap(cards.allElementsBoundByIndex.first(where: containsFocus))
+ XCTAssertNotEqual(first.identifier, second.identifier)
+ XCTAssertEqual(first.frame.midY, second.frame.midY, accuracy: 30)
+ capture(app, name: "Horizontal media navigation")
+ XCUIRemote.shared.press(.down)
+ let nextRow = try XCTUnwrap(cards.allElementsBoundByIndex.first(where: containsFocus),
+ "Down must reach media in the next shelf, without a header button stop.")
+ XCTAssertNotEqual(nextRow.identifier, second.identifier)
+ capture(app, name: "Down goes directly to next shelf")
+ XCUIRemote.shared.press(.up)
+ let returned = try XCTUnwrap(cards.allElementsBoundByIndex.first(where: containsFocus))
+ XCTAssertEqual(returned.identifier, second.identifier)
+ XCUIRemote.shared.press(.select)
+ XCTAssertTrue(app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'detail-play.'")).firstMatch
+ .waitForExistence(timeout: 15))
+ let originalPlayID = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'detail-play.'")).firstMatch.identifier
+ XCUIRemote.shared.press(.menu)
+ XCTAssertTrue(cards.firstMatch.waitForExistence(timeout: 5))
+ capture(app, name: "Back to the originating shelf")
+ // AX can omit the restored focus flag. Select tests the remote's actual target.
+ XCUIRemote.shared.press(.select)
+ XCTAssertTrue(app.buttons[originalPlayID].waitForExistence(timeout: 10),
+ "Select after Back must reopen the same episode or movie.")
+ XCUIRemote.shared.press(.menu)
+ }
+
+ private func containsFocus(_ element: XCUIElement) -> Bool {
+ element.hasFocus || element.descendants(matching: .any).allElementsBoundByIndex.contains(where: \.hasFocus)
+ }
+
+ private func selectMenuChoice(_ title: String, in app: XCUIApplication) throws {
+ let choice = app.cells.containing(.any, identifier: title).firstMatch
+ XCTAssertTrue(choice.waitForExistence(timeout: 5))
+ for _ in 0..<30 {
+ if containsFocus(choice) { break }
+ let focused = try XCTUnwrap(app.cells.allElementsBoundByIndex.first(where: containsFocus))
+ XCUIRemote.shared.press(choice.frame.midY < focused.frame.midY ? .up : .down)
+ }
+ XCTAssertTrue(containsFocus(choice))
+ XCUIRemote.shared.press(.select)
+ }
+
+ func testHomeDetailAndBackWithNativeRemote() throws {
+ continueAfterFailure = false
+ let app = XCUIApplication()
+ app.launch()
+ let cards = app.descendants(matching: .any).matching(
+ NSPredicate(format: "identifier BEGINSWITH 'media.'")
+ )
+ guard cards.firstMatch.waitForExistence(timeout: 20) else {
+ capture(app, name: "Home prerequisite")
+ throw XCTSkip("Requires a signed-in Plex server with media on Home.")
+ }
+ capture(app, name: "Home")
+ XCUIRemote.shared.press(.down)
+ let movie = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'media.movie.'")).firstMatch
+ guard movie.exists else { throw XCTSkip("Requires a movie in the first Home row.") }
+ for _ in 0..<12 {
+ if movie.hasFocus { break }
+ let current = try XCTUnwrap(app.buttons.allElementsBoundByIndex.first(where: \.hasFocus))
+ XCUIRemote.shared.press(movie.frame.midX < current.frame.midX ? .left : .right)
+ }
+ XCTAssertTrue(movie.hasFocus)
+ let focused = try XCTUnwrap(cards.allElementsBoundByIndex.first(where: \.hasFocus), "Down should focus a media card.")
+ let cardIdentifier = focused.identifier
+ capture(app, name: "Focused Home card")
+ XCUIRemote.shared.press(.select)
+ let playButton = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'detail-play.'")).firstMatch
+ XCTAssertTrue(playButton.waitForExistence(timeout: 15), "Select should open media details with a playback action.")
+ XCTAssertTrue(playButton.hasFocus, "Opening details should focus Play or Resume, not the description.")
+ let originalPlaybackLabel = playButton.label
+ let trailer = app.buttons.matching(NSPredicate(format: "label BEGINSWITH 'Trailer for '")).firstMatch
+ if trailer.exists {
+ XCTAssertEqual(trailer.staticTexts.count, 0, "Trailer should display only its icon.")
+ }
+ capture(app, name: "Media detail")
+ let summary = app.buttons["detail-summary"]
+ if summary.exists {
+ XCTAssertFalse(app.buttons["Full Synopsis"].exists, "The description itself replaces the Info action.")
+ XCUIRemote.shared.press(.up)
+ XCTAssertTrue(summary.hasFocus, "Up from Play should focus the description.")
+ capture(app, name: "Focused description")
+ XCUIRemote.shared.press(.select)
+ XCTAssertTrue(app.buttons["Done"].waitForExistence(timeout: 5))
+ capture(app, name: "Full description")
+ XCUIRemote.shared.press(.menu)
+ XCTAssertTrue(app.buttons["Done"].waitForNonExistence(timeout: 5))
+ XCUIRemote.shared.press(.down)
+ }
+ XCUIRemote.shared.press(.menu)
+ let restored = cards.matching(identifier: cardIdentifier).firstMatch
+ XCTAssertTrue(restored.waitForExistence(timeout: 5))
+ capture(app, name: "Restored Home focus")
+ // SwiftUI's restored card can be highlighted while AX reports no focused element.
+ // Verify the remote's effective target rather than relying on that stale AX flag.
+ XCUIRemote.shared.press(.select)
+ XCTAssertTrue(playButton.waitForExistence(timeout: 5))
+ XCTAssertEqual(playButton.label, originalPlaybackLabel, "Select after Back should reopen the originating title.")
+ XCUIRemote.shared.press(.menu)
+ }
+
+ func testSeasonSwitchAndEpisodeSelectionStayOnOneDetailPage() throws {
+ continueAfterFailure = false
+ let app = XCUIApplication()
+ app.launch()
+ let episodeCards = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'media.episode.'"))
+ guard episodeCards.firstMatch.waitForExistence(timeout: 20) else {
+ throw XCTSkip("Requires an episode on Home and a series with multiple seasons.")
+ }
+ let target = episodeCards.firstMatch
+ let originIdentifier = target.identifier
+ XCUIRemote.shared.press(.down)
+ for _ in 0..<12 {
+ if target.hasFocus { break }
+ let current = try XCTUnwrap(app.buttons.allElementsBoundByIndex.first(where: \.hasFocus))
+ XCUIRemote.shared.press(target.frame.midX < current.frame.midX ? .left : .right)
+ }
+ XCTAssertTrue(target.hasFocus, "The Home episode must be reachable with the remote.")
+ XCUIRemote.shared.press(.select)
+ let picker = app.buttons["season-picker"]
+ XCTAssertTrue(picker.waitForExistence(timeout: 15), "Requires the Home episode's multi-season picker.")
+ let oldSeason = picker.value as? String
+ let rowCards = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'media.episode.'"))
+ XCTAssertTrue(rowCards.firstMatch.waitForExistence(timeout: 10))
+ let oldEpisodeIDs = Set(rowCards.allElementsBoundByIndex.map(\.identifier))
+ capture(app, name: "Episode detail")
+ XCUIRemote.shared.press(.down)
+ capture(app, name: "Down from episode playback")
+ XCUIRemote.shared.press(.select)
+ capture(app, name: "Open season picker")
+ XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 5), "Select should open the native season menu.")
+ XCUIRemote.shared.press(.down)
+ let nextOption = try XCTUnwrap(app.cells.allElementsBoundByIndex.first(where: \.hasFocus))
+ let nextSeason = nextOption.descendants(matching: .any)
+ .matching(NSPredicate(format: "label BEGINSWITH 'Season '"))
+ .firstMatch.label
+ XCTAssertNotEqual(nextSeason, oldSeason)
+ XCUIRemote.shared.press(.select)
+ XCTAssertTrue(picker.waitForExistence(timeout: 5))
+ XCTAssertEqual(picker.value as? String, nextSeason)
+ let episodesChanged = expectation(for: NSPredicate { _, _ in
+ let ids = Set(rowCards.allElementsBoundByIndex.map(\.identifier))
+ return !ids.isEmpty && ids != oldEpisodeIDs
+ }, evaluatedWith: app)
+ XCTAssertEqual(XCTWaiter.wait(for: [episodesChanged], timeout: 10), .completed,
+ "Changing seasons must replace the episode row, not just the heading.")
+ capture(app, name: "Changed season")
+ XCUIRemote.shared.press(.down)
+ let selectedEpisode = try XCTUnwrap(rowCards.allElementsBoundByIndex.first(where: { card in
+ card.hasFocus || card.descendants(matching: .any).allElementsBoundByIndex.contains(where: \.hasFocus)
+ }))
+ let ratingKey = try XCTUnwrap(selectedEpisode.identifier.split(separator: ".").last)
+ XCUIRemote.shared.press(.select)
+ XCTAssertTrue(app.buttons["detail-play.\(ratingKey)"].waitForExistence(timeout: 10))
+ capture(app, name: "Selected episode in place")
+ XCUIRemote.shared.press(.menu)
+ XCTAssertTrue(app.buttons.matching(identifier: originIdentifier).firstMatch.waitForExistence(timeout: 5), "One Back should return Home after selecting an episode.")
+ }
+
+ func testNativePlaybackResumesAdvancesAndDismisses() throws {
+ continueAfterFailure = false
+ let app = XCUIApplication()
+ app.launch()
+ let cards = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'media.'"))
+ guard cards.firstMatch.waitForExistence(timeout: 20) else {
+ capture(app, name: "Playback Home prerequisite unavailable")
+ throw XCTSkip("Requires a signed-in Plex server with playable Home media.")
+ }
+ XCUIRemote.shared.press(.down)
+ XCUIRemote.shared.press(.select)
+ let play = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'detail-play.'")).firstMatch
+ XCTAssertTrue(play.waitForExistence(timeout: 15))
+ let detailIdentifier = play.identifier
+ let resumeTime = play.label.split(separator: " ").compactMap { clockSeconds(String($0)) }.first ?? 0
+ capture(app, name: "Before playback")
+ XCUIRemote.shared.press(.select)
+ let player = app.otherElements["native-player"]
+ XCTAssertTrue(player.waitForExistence(timeout: 30), "Playback should present the native AVKit player.")
+ let elapsed = player.otherElements["AXElapsedTime"]
+ let resumed = expectation(for: NSPredicate { _, _ in
+ guard elapsed.exists, let seconds = self.clockSeconds(elapsed.label) else { return false }
+ return seconds >= max(1, resumeTime - 15)
+ }, evaluatedWith: player)
+ let resumeResult = XCTWaiter.wait(for: [resumed], timeout: 45)
+ capture(app, name: "Native playback readiness")
+ XCTAssertEqual(resumeResult, .completed, "The native playback clock must reach the saved resume position.")
+ let initialTime = try XCTUnwrap(clockSeconds(elapsed.label))
+ let advancing = expectation(for: NSPredicate { _, _ in
+ guard elapsed.exists, let seconds = self.clockSeconds(elapsed.label) else { return false }
+ return seconds >= initialTime + 3
+ }, evaluatedWith: player)
+ let advancingResult = XCTWaiter.wait(for: [advancing], timeout: 15)
+ capture(app, name: "Native playback advancing")
+ XCTAssertEqual(advancingResult, .completed, "Playback must advance after resuming.")
+ XCUIRemote.shared.press(.playPause)
+ let pausedTime = try XCTUnwrap(clockSeconds(elapsed.label))
+ let remainsPaused = expectation(for: NSPredicate { _, _ in
+ guard elapsed.exists, let seconds = self.clockSeconds(elapsed.label) else { return false }
+ return seconds > pausedTime + 1
+ }, evaluatedWith: player)
+ remainsPaused.isInverted = true
+ XCTAssertEqual(XCTWaiter.wait(for: [remainsPaused], timeout: 4), .completed,
+ "The native remote's Play Pause command must stop playback progress.")
+ XCTAssertFalse(player.cells["Audio & Subtitles"].exists,
+ "Plex-specific adjustments belong inside Playback options, alongside AVKit's native track controls.")
+ capture(app, name: "Native playback after Play Pause")
+ XCUIRemote.shared.press(.playPause)
+ XCUIRemote.shared.press(.right)
+ let soughtForward = expectation(for: NSPredicate { _, _ in
+ guard elapsed.exists, let seconds = self.clockSeconds(elapsed.label) else { return false }
+ return seconds >= pausedTime + 8
+ }, evaluatedWith: player)
+ let forwardResult = XCTWaiter.wait(for: [soughtForward], timeout: 6)
+ capture(app, name: "Native seek forward")
+ XCTAssertEqual(forwardResult, .completed, "Right should seek forward using AVKit's native skip control.")
+ let forwardTime = try XCTUnwrap(clockSeconds(elapsed.label))
+ XCUIRemote.shared.press(.left)
+ let soughtBackward = expectation(for: NSPredicate { _, _ in
+ guard elapsed.exists, let seconds = self.clockSeconds(elapsed.label) else { return false }
+ return seconds <= forwardTime - 5
+ }, evaluatedWith: player)
+ let backwardResult = XCTWaiter.wait(for: [soughtBackward], timeout: 6)
+ capture(app, name: "Native seek backward")
+ XCTAssertEqual(backwardResult, .completed, "Left should seek backward using AVKit's native skip control.")
+ XCUIRemote.shared.press(.playPause)
+ let finalPosition = try XCTUnwrap(clockSeconds(elapsed.label))
+ XCUIRemote.shared.press(.up)
+ capture(app, name: "Native transport focus above timeline")
+ XCTAssertTrue(player.cells["Playback"].hasFocus)
+ XCUIRemote.shared.press(.select)
+ XCTAssertTrue(app.cells.matching(NSPredicate(format: "label BEGINSWITH 'Quality'")).firstMatch
+ .waitForExistence(timeout: 3), "Playback options must open as a native menu.")
+ capture(app, name: "Playback options menu")
+ XCUIRemote.shared.press(.menu)
+ let audioControl = player.cells["AVAudibleSettings"]
+ if audioControl.exists {
+ try focusTransportControl(audioControl, player: player)
+ XCUIRemote.shared.press(.select)
+ XCTAssertTrue(app.cells.matching(NSPredicate(format: "label BEGINSWITH 'selected, '")).firstMatch
+ .waitForExistence(timeout: 3), "The native audio menu should identify the active track.")
+ capture(app, name: "Native audio menu")
+ XCUIRemote.shared.press(.menu)
+ }
+ let subtitleControl = player.cells["AVLegibleSettings"]
+ if subtitleControl.exists {
+ try focusTransportControl(subtitleControl, player: player)
+ XCUIRemote.shared.press(.select)
+ XCTAssertTrue(app.cells.matching(identifier: "AVSubtitlesOffAction").firstMatch
+ .waitForExistence(timeout: 3), "The native subtitle menu must open with its Off control.")
+ capture(app, name: "Native subtitle menu")
+ XCUIRemote.shared.press(.menu)
+ }
+ for _ in 0..<3 {
+ XCUIRemote.shared.press(.menu)
+ if player.waitForNonExistence(timeout: 1) { break }
+ }
+ XCTAssertTrue(player.waitForNonExistence(timeout: 5))
+ XCTAssertTrue(app.buttons[detailIdentifier].waitForExistence(timeout: 10))
+ let savedPosition = try XCTUnwrap(app.buttons[detailIdentifier].label.split(separator: " ")
+ .compactMap { clockSeconds(String($0)) }.first)
+ XCTAssertLessThanOrEqual(abs(savedPosition - finalPosition), 2,
+ "Closing after seeking should save the final position to Plex.")
+ capture(app, name: "Returned from playback")
+ }
+
+ func testNextEpisodeStartsPlayingAfterPausedQueueSelection() throws {
+ continueAfterFailure = false
+ let app = XCUIApplication()
+ app.launch()
+ let homeEpisode = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'media.episode.'")).firstMatch
+ guard homeEpisode.waitForExistence(timeout: 20) else {
+ throw XCTSkip("Requires an episode on Home with another episode in its season.")
+ }
+ XCUIRemote.shared.press(.down)
+ for _ in 0..<12 {
+ if homeEpisode.hasFocus { break }
+ let current = try XCTUnwrap(app.buttons.allElementsBoundByIndex.first(where: \.hasFocus))
+ XCUIRemote.shared.press(homeEpisode.frame.midX < current.frame.midX ? .left : .right)
+ }
+ XCTAssertTrue(homeEpisode.hasFocus)
+ XCUIRemote.shared.press(.select)
+ let picker = app.buttons["season-picker"]
+ XCTAssertTrue(picker.waitForExistence(timeout: 15))
+ let row = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'media.episode.'"))
+ XCTAssertTrue(row.firstMatch.waitForExistence(timeout: 10))
+ guard row.count >= 2 else { throw XCTSkip("Requires at least two episodes in the season.") }
+ let firstEpisode = row.element(boundBy: 0)
+ let nextEpisode = row.element(boundBy: 1)
+ let nextTitle = try XCTUnwrap(nextEpisode.staticTexts.allElementsBoundByIndex.first?.label)
+ .split(separator: ".", maxSplits: 1).last.map(String.init)?.trimmingCharacters(in: .whitespaces) ?? ""
+ XCTAssertFalse(nextTitle.isEmpty)
+ let ratingKey = try XCTUnwrap(firstEpisode.identifier.split(separator: ".").last)
+ XCUIRemote.shared.press(.down)
+ XCUIRemote.shared.press(.down)
+ XCTAssertTrue(containsFocus(firstEpisode))
+ XCUIRemote.shared.press(.select)
+ let play = app.buttons["detail-play.\(ratingKey)"]
+ XCTAssertTrue(play.waitForExistence(timeout: 10))
+ for _ in 0..<3 {
+ if play.hasFocus { break }
+ XCUIRemote.shared.press(.up)
+ }
+ XCTAssertTrue(play.hasFocus)
+ XCUIRemote.shared.press(.select)
+ let player = app.otherElements["native-player"]
+ XCTAssertTrue(player.waitForExistence(timeout: 30))
+ let elapsed = player.otherElements["AXElapsedTime"]
+ let started = expectation(for: NSPredicate { _, _ in
+ elapsed.exists && (self.clockSeconds(elapsed.label) ?? 0) >= 2
+ }, evaluatedWith: player)
+ let startedResult = XCTWaiter.wait(for: [started], timeout: 45)
+ capture(app, name: "Queue source playback")
+ XCTAssertEqual(startedResult, .completed)
+ XCUIRemote.shared.press(.playPause)
+ let pausedTime = try XCTUnwrap(clockSeconds(elapsed.label))
+ let pause = expectation(for: NSPredicate { _, _ in
+ (self.clockSeconds(elapsed.label) ?? 0) > pausedTime + 1
+ }, evaluatedWith: player)
+ pause.isInverted = true
+ XCTAssertEqual(XCTWaiter.wait(for: [pause], timeout: 3), .completed)
+ XCUIRemote.shared.press(.up)
+ try focusTransportControl(player.cells["Queue & Timing"], player: player)
+ XCUIRemote.shared.press(.select)
+ try selectMenuChoice("Queue", in: app)
+ capture(app, name: "Native episode queue menu")
+ let next = app.cells.containing(.staticText, identifier: "Next").firstMatch
+ XCTAssertTrue(next.waitForExistence(timeout: 5))
+ XCTAssertTrue(next.isEnabled)
+ XCTAssertTrue(next.staticTexts[nextTitle].exists, "Next must identify the next episode from the season.")
+ try selectMenuChoice("Next", in: app)
+ let nextVisible = expectation(for: NSPredicate { _, _ in
+ player.staticTexts.allElementsBoundByIndex.contains { $0.label.contains(nextTitle) }
+ }, evaluatedWith: player)
+ let nextResult = XCTWaiter.wait(for: [nextVisible], timeout: 45)
+ capture(app, name: "Queue replacement identity")
+ XCTAssertEqual(nextResult, .completed, "AVKit must display the selected next episode.")
+ let initialTime = try XCTUnwrap(clockSeconds(elapsed.label))
+ let advancing = expectation(for: NSPredicate { _, _ in
+ elapsed.exists && (self.clockSeconds(elapsed.label) ?? 0) >= initialTime + 3
+ }, evaluatedWith: player)
+ let advancingResult = XCTWaiter.wait(for: [advancing], timeout: 30)
+ capture(app, name: "Next episode decoded and advancing")
+ XCTAssertEqual(advancingResult, .completed, "Next must start playback even though the source episode was paused.")
+ for _ in 0..<3 {
+ XCUIRemote.shared.press(.menu)
+ if player.waitForNonExistence(timeout: 1) { break }
+ }
+ XCTAssertTrue(player.waitForNonExistence(timeout: 5))
+ }
+
+ private func focusTransportControl(_ target: XCUIElement, player: XCUIElement) throws {
+ for _ in 0..<6 {
+ if target.hasFocus { return }
+ let current = try XCTUnwrap(player.cells.allElementsBoundByIndex.first(where: \.hasFocus))
+ XCUIRemote.shared.press(target.frame.midX < current.frame.midX ? .left : .right)
+ }
+ XCTAssertTrue(target.hasFocus, "Native transport controls must be reachable with the remote.")
+ }
+
+ func testMovieExtrasPlayDirectlyAndReturnToShelf() throws {
+ continueAfterFailure = false
+ let app = XCUIApplication()
+ app.launch()
+ let movie = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'media.movie.'")).firstMatch
+ guard movie.waitForExistence(timeout: 20) else {
+ throw XCTSkip("Requires a signed-in server with a movie on Home.")
+ }
+ XCUIRemote.shared.press(.down)
+ for _ in 0..<12 {
+ if movie.hasFocus { break }
+ let current = try XCTUnwrap(app.buttons.allElementsBoundByIndex.first(where: \.hasFocus))
+ XCUIRemote.shared.press(movie.frame.midX < current.frame.midX ? .left : .right)
+ }
+ XCTAssertTrue(movie.hasFocus)
+ XCUIRemote.shared.press(.select)
+ XCTAssertTrue(app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'detail-play.'")).firstMatch
+ .waitForExistence(timeout: 15))
+ let extras = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'media.clip.'"))
+ for _ in 0..<16 {
+ if extras.allElementsBoundByIndex.contains(where: \.hasFocus) { break }
+ XCUIRemote.shared.press(.down)
+ }
+ capture(app, name: "Movie discovery shelves")
+ XCTAssertFalse(app.staticTexts["Couldn’t Load Extras"].exists)
+ XCTAssertFalse(app.staticTexts["Couldn’t Load Related Content"].exists)
+ guard let extra = extras.allElementsBoundByIndex.first(where: \.hasFocus) else {
+ throw XCTSkip("The selected movie requires a playable extra for this live check.")
+ }
+ let extraIdentifier = extra.identifier
+ let ratingKey = String(extraIdentifier.dropFirst("media.clip.".count))
+ XCUIRemote.shared.press(.select)
+ let player = app.otherElements["native-player"]
+ XCTAssertTrue(player.waitForExistence(timeout: 30), "One Select on an extra must start playback directly.")
+ XCTAssertFalse(app.buttons["detail-play.\(ratingKey)"].exists, "Extras must not push their own detail page.")
+ let elapsed = player.otherElements["AXElapsedTime"]
+ let playing = expectation(for: NSPredicate { _, _ in
+ guard elapsed.exists, let seconds = self.clockSeconds(elapsed.label) else { return false }
+ return seconds >= 2
+ }, evaluatedWith: player)
+ let playbackResult = XCTWaiter.wait(for: [playing], timeout: 45)
+ capture(app, name: "Extra playback")
+ XCTAssertEqual(playbackResult, .completed, "The selected extra must play in AVKit.")
+ for _ in 0..<3 {
+ XCUIRemote.shared.press(.menu)
+ if player.waitForNonExistence(timeout: 1) { break }
+ }
+ XCTAssertTrue(player.waitForNonExistence(timeout: 5))
+ XCTAssertTrue(app.buttons[extraIdentifier].waitForExistence(timeout: 5),
+ "Closing an extra must return to its shelf on the originating movie.")
+ XCTAssertFalse(app.buttons["detail-play.\(ratingKey)"].exists)
+ capture(app, name: "Returned to originating Extras shelf")
+ // Verify the effective remote target even if SwiftUI's AX focus flag is stale.
+ XCUIRemote.shared.press(.select)
+ XCTAssertTrue(player.waitForExistence(timeout: 30), "Focus should return to the extra for direct replay.")
+ for _ in 0..<3 {
+ XCUIRemote.shared.press(.menu)
+ if player.waitForNonExistence(timeout: 1) { break }
+ }
+ XCTAssertTrue(player.waitForNonExistence(timeout: 5))
+ XCUIRemote.shared.press(.menu)
+ XCTAssertTrue(movie.waitForExistence(timeout: 5), "One Back from the parent detail should return Home.")
+ }
+
+ private func clockSeconds(_ value: String) -> Int? {
+ let components = value.split(separator: ":")
+ guard (2...3).contains(components.count) else { return nil }
+ let numbers = components.compactMap { Int($0) }
+ guard numbers.count == components.count else { return nil }
+ return numbers.reduce(0) { $0 * 60 + $1 }
+ }
+
+ private func capture(_ app: XCUIApplication, name: String) {
+ let screenshot = XCTAttachment(screenshot: app.screenshot())
+ screenshot.name = name
+ screenshot.lifetime = .keepAlways
+ add(screenshot)
+ let hierarchy = XCTAttachment(string: app.debugDescription)
+ hierarchy.name = "\(name) hierarchy"
+ hierarchy.lifetime = .keepAlways
+ add(hierarchy)
+ }
+}
+#endif
diff --git a/Sources/PlexBar/App/PlexBarApp.swift b/Sources/PlexBar/App/PlexBarApp.swift
deleted file mode 100644
index 4097cb5..0000000
--- a/Sources/PlexBar/App/PlexBarApp.swift
+++ /dev/null
@@ -1,91 +0,0 @@
-import AppKit
-import SwiftUI
-
-final class AppDelegate: NSObject, NSApplicationDelegate {
- func applicationDidFinishLaunching(_ notification: Notification) {
- // This is intentionally a menu-bar-first app without a Dock icon.
- NSApp.setActivationPolicy(.accessory)
- }
-}
-
-@main
-struct PlexBarApp: App {
- @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
- @State private var settingsStore: PlexSettingsStore
- @State private var connectionStore: PlexConnectionStore
- @State private var authStore: PlexAuthStore
- @State private var sessionStore: PlexSessionStore
- @State private var historyStore: PlexHistoryStore
- @State private var libraryStore: PlexLibraryStore
- @State private var serverPreviewStore: PlexServerPreviewStore
- private let systemLifecycleObserver: PlexSystemLifecycleObserver
- private let updateService: PlexUpdateService
-
- init() {
- let runtime = PlexAppRuntime.current()
- let settingsStore = runtime.settingsStore
- let resolver = runtime.connectionResolver
- let connectionStore = PlexConnectionStore(settings: settingsStore, resolver: resolver)
- let sessionStore = PlexSessionStore(
- connectionStore: connectionStore,
- client: runtime.apiClient,
- geoIPClient: runtime.geoIPClient,
- eventsClient: runtime.sessionEventsClient
- )
- let libraryStore = PlexLibraryStore(connectionStore: connectionStore, client: runtime.apiClient)
- let historyStore = PlexHistoryStore(
- connectionStore: connectionStore,
- libraryStore: libraryStore,
- client: runtime.apiClient
- )
- let serverPreviewStore = PlexServerPreviewStore(client: runtime.apiClient, resolver: resolver)
- _settingsStore = State(initialValue: settingsStore)
- _connectionStore = State(initialValue: connectionStore)
- _sessionStore = State(initialValue: sessionStore)
- _historyStore = State(initialValue: historyStore)
- _libraryStore = State(initialValue: libraryStore)
- _serverPreviewStore = State(initialValue: serverPreviewStore)
- _authStore = State(initialValue: PlexAuthStore(
- settings: settingsStore,
- connectionStore: connectionStore,
- sessionStore: sessionStore,
- historyStore: historyStore,
- libraryStore: libraryStore,
- client: runtime.authClient
- ))
- systemLifecycleObserver = PlexSystemLifecycleObserver {
- sessionStore.refreshNow()
- }
- updateService = PlexUpdateService()
- }
-
- var body: some Scene {
- Settings {
- SettingsView(
- settingsStore: settingsStore,
- connectionStore: connectionStore,
- authStore: authStore,
- previewStore: serverPreviewStore,
- sessionStore: sessionStore,
- historyStore: historyStore,
- updateService: updateService
- )
- }
- .defaultSize(width: 480, height: 520)
- .windowResizability(.contentSize)
-
- MenuBarExtra {
- MenuBarContentView(
- settingsStore: settingsStore,
- connectionStore: connectionStore,
- authStore: authStore,
- sessionStore: sessionStore,
- historyStore: historyStore,
- libraryStore: libraryStore
- )
- } label: {
- MenuBarLabelView(streamCount: sessionStore.activeStreamCount)
- }
- .menuBarExtraStyle(.window)
- }
-}
diff --git a/Sources/PlexBar/Models/PlexAccount.swift b/Sources/PlexBar/Models/PlexAccount.swift
deleted file mode 100644
index 52b2ae4..0000000
--- a/Sources/PlexBar/Models/PlexAccount.swift
+++ /dev/null
@@ -1,13 +0,0 @@
-import Foundation
-
-struct PlexAccount: Decodable, Identifiable, Equatable {
- let id: Int
- let name: String
- let thumb: String?
-
- enum CodingKeys: String, CodingKey {
- case id
- case name
- case thumb
- }
-}
diff --git a/Sources/PlexBar/Models/PlexAuthenticatedUser.swift b/Sources/PlexBar/Models/PlexAuthenticatedUser.swift
deleted file mode 100644
index 268193b..0000000
--- a/Sources/PlexBar/Models/PlexAuthenticatedUser.swift
+++ /dev/null
@@ -1,28 +0,0 @@
-import Foundation
-
-struct PlexAuthenticatedUser: Decodable, Equatable, Identifiable {
- let id: Int
- let username: String
- let title: String?
- let email: String?
- let thumb: String?
- let friendlyName: String?
-
- var displayName: String {
- title?.nilIfBlank ?? username
- }
-
- var displayEmail: String? {
- email?.nilIfBlank
- }
-
- var displayUsername: String? {
- let normalizedUsername = username.nilIfBlank
- guard let normalizedUsername,
- normalizedUsername != displayName else {
- return nil
- }
-
- return normalizedUsername
- }
-}
diff --git a/Sources/PlexBar/Models/PlexResolvedConnection.swift b/Sources/PlexBar/Models/PlexResolvedConnection.swift
deleted file mode 100644
index f4772b7..0000000
--- a/Sources/PlexBar/Models/PlexResolvedConnection.swift
+++ /dev/null
@@ -1,25 +0,0 @@
-import Foundation
-
-enum PlexConnectionKind: String, Codable, Sendable {
- case local
- case remote
- case relay
-
- var displayName: String {
- switch self {
- case .local:
- return "Local"
- case .remote:
- return "Remote"
- case .relay:
- return "Relay"
- }
- }
-}
-
-struct PlexResolvedConnection: Equatable, Sendable {
- let serverID: String
- let url: URL
- let kind: PlexConnectionKind
- let validatedAt: Date
-}
diff --git a/Sources/PlexBar/Models/PlexServerResource.swift b/Sources/PlexBar/Models/PlexServerResource.swift
deleted file mode 100644
index 29db145..0000000
--- a/Sources/PlexBar/Models/PlexServerResource.swift
+++ /dev/null
@@ -1,42 +0,0 @@
-import Foundation
-
-struct PlexServerResource: Identifiable, Equatable {
- let id: String
- let name: String
- let productVersion: String?
- let accessToken: String
- let connections: [PlexServerConnection]
-
- var displayProductVersion: String? {
- guard let productVersion = productVersion?.nilIfBlank else {
- return nil
- }
-
- return productVersion.split(separator: "-", maxSplits: 1).first.map(String.init)
- }
-}
-
-struct PlexServerConnection: Equatable {
- let uri: URL
- let local: Bool
- let relay: Bool
-
- var kind: PlexConnectionKind {
- if relay {
- return .relay
- }
-
- return local ? .local : .remote
- }
-
- var priorityTier: Int {
- switch kind {
- case .local:
- return 0
- case .remote:
- return 1
- case .relay:
- return 2
- }
- }
-}
diff --git a/Sources/PlexBar/Resources/MockServer/art/tv/abbott-and-costello.png b/Sources/PlexBar/Resources/MockServer/art/tv/abbott-and-costello.png
deleted file mode 100644
index a813381..0000000
Binary files a/Sources/PlexBar/Resources/MockServer/art/tv/abbott-and-costello.png and /dev/null differ
diff --git a/Sources/PlexBar/Resources/MockServer/art/tv/adventures-of-ozzie-and-harriet.png b/Sources/PlexBar/Resources/MockServer/art/tv/adventures-of-ozzie-and-harriet.png
deleted file mode 100644
index ca91e04..0000000
Binary files a/Sources/PlexBar/Resources/MockServer/art/tv/adventures-of-ozzie-and-harriet.png and /dev/null differ
diff --git a/Sources/PlexBar/Resources/MockServer/art/tv/one-step-beyond.png b/Sources/PlexBar/Resources/MockServer/art/tv/one-step-beyond.png
deleted file mode 100644
index 9e319d8..0000000
Binary files a/Sources/PlexBar/Resources/MockServer/art/tv/one-step-beyond.png and /dev/null differ
diff --git a/Sources/PlexBar/Resources/MockServer/art/tv/originals/adventures-of-ozzie-and-harriet.png b/Sources/PlexBar/Resources/MockServer/art/tv/originals/adventures-of-ozzie-and-harriet.png
deleted file mode 100644
index 83cfc52..0000000
Binary files a/Sources/PlexBar/Resources/MockServer/art/tv/originals/adventures-of-ozzie-and-harriet.png and /dev/null differ
diff --git a/Sources/PlexBar/Resources/MockServer/mock-server.json b/Sources/PlexBar/Resources/MockServer/mock-server.json
deleted file mode 100644
index 8b940cd..0000000
--- a/Sources/PlexBar/Resources/MockServer/mock-server.json
+++ /dev/null
@@ -1,518 +0,0 @@
-{
- "authenticatedUser": {
- "id": 16,
- "username": "D0loresH4ze",
- "email": "d0loresh4ze@proton.me",
- "thumb": "/mock/avatars/darlene-alderson.png"
- },
- "server": {
- "id": "debug-mock-server",
- "name": "Mock Server",
- "productVersion": "1.43.1.10611-1e34174b1",
- "accessToken": "plexbar-debug-mock-server-token",
- "connections": [
- {
- "uri": "https://demo.plexbar.local:32400",
- "local": true,
- "relay": false
- }
- ]
- },
- "users": [
- {
- "id": 11,
- "name": "scully",
- "avatar": "/mock/avatars/dana-scully.png"
- },
- {
- "id": 12,
- "name": "Elliot",
- "avatar": "/mock/avatars/elliot-alderson.png"
- },
- {
- "id": 13,
- "name": "petit_prince",
- "avatar": "/mock/avatars/le-petit-prince.png"
- },
- {
- "id": 14,
- "name": "popeye23",
- "avatar": "/mock/avatars/popeye.png"
- },
- {
- "id": 15,
- "name": "TommyS",
- "avatar": "/mock/avatars/tommy-shelby.png"
- },
- {
- "id": 16,
- "name": "D0loresH4ze",
- "avatar": "/mock/avatars/darlene-alderson.png"
- },
- {
- "id": 17,
- "name": "scrump-toggins",
- "avatar": "/mock/avatars/scrump-toggins.png"
- }
- ],
- "movies": [
- {
- "id": "1101",
- "title": "Charade",
- "year": 1963,
- "poster": "/mock/art/movies/charade.png",
- "art": "/mock/art/movies/charade.png",
- "originallyAvailableAt": "1963-12-05"
- },
- {
- "id": "1102",
- "title": "Night of the Living Dead",
- "year": 1968,
- "poster": "/mock/art/movies/night-of-the-living-dead.png",
- "art": "/mock/art/movies/night-of-the-living-dead.png",
- "originallyAvailableAt": "1968-10-01"
- },
- {
- "id": "1103",
- "title": "Sherlock Jr.",
- "year": 1924,
- "poster": "/mock/art/movies/sherlock-jr.png",
- "art": "/mock/art/movies/sherlock-jr.png",
- "originallyAvailableAt": "1924-04-21"
- }
- ],
- "shows": [
- {
- "id": "2103",
- "title": "One Step Beyond",
- "poster": "/mock/art/tv/one-step-beyond.png",
- "art": "/mock/art/tv/one-step-beyond.png"
- },
- {
- "id": "2102",
- "title": "The Adventures of Ozzie and Harriet",
- "poster": "/mock/art/tv/adventures-of-ozzie-and-harriet.png",
- "art": "/mock/art/tv/adventures-of-ozzie-and-harriet.png"
- },
- {
- "id": "2101",
- "title": "The Abbott and Costello Show",
- "poster": "/mock/art/tv/abbott-and-costello.png",
- "art": "/mock/art/tv/abbott-and-costello.png"
- }
- ],
- "episodes": [
- {
- "id": "2201",
- "showID": "2103",
- "title": "The Bride Possessed",
- "seasonNumber": 1,
- "episodeNumber": 14,
- "originallyAvailableAt": "1959-01-20"
- },
- {
- "id": "2202",
- "showID": "2102",
- "title": "The Girls' Club",
- "seasonNumber": 1,
- "episodeNumber": 5,
- "originallyAvailableAt": "1952-11-14"
- },
- {
- "id": "2203",
- "showID": "2101",
- "title": "The Dentist",
- "seasonNumber": 1,
- "episodeNumber": 3,
- "originallyAvailableAt": "1953-01-16"
- }
- ],
- "audiobooks": [
- {
- "id": "3101",
- "title": "Dracula",
- "year": 1897,
- "cover": "/mock/art/audiobooks/dracula.png",
- "artistTitle": "Bram Stoker",
- "albumTitle": "Dracula",
- "trackTitle": "Dracula"
- },
- {
- "id": "3102",
- "title": "The Time Machine",
- "year": 1895,
- "cover": "/mock/art/audiobooks/the-time-machine.png",
- "artistTitle": "H. G. Wells",
- "albumTitle": "The Time Machine",
- "trackTitle": "The Time Machine"
- },
- {
- "id": "3103",
- "title": "The War of the Worlds",
- "year": 1898,
- "cover": "/mock/art/audiobooks/war-of-the-worlds.png",
- "artistTitle": "H. G. Wells",
- "albumTitle": "The War of the Worlds",
- "trackTitle": "The War of the Worlds"
- }
- ],
- "activeSessions": [
- {
- "sessionKey": "stream-1",
- "userID": 11,
- "mediaType": "movie",
- "mediaID": "1101",
- "duration": 5520000,
- "viewOffset": 3146000,
- "player": {
- "address": "192.168.1.11",
- "machineIdentifier": "den-apple-tv",
- "platform": "tvOS",
- "product": "Plex for Apple TV",
- "remotePublicAddress": "198.51.100.11",
- "state": "playing",
- "title": "Apple TV",
- "local": true,
- "relayed": false,
- "secure": true
- },
- "session": {
- "id": "playback-1",
- "bandwidth": 12000,
- "location": "lan"
- },
- "mediaDecision": "direct play"
- },
- {
- "sessionKey": "stream-2",
- "userID": 12,
- "mediaType": "movie",
- "mediaID": "1102",
- "duration": 4560000,
- "viewOffset": 3750000,
- "player": {
- "address": "172.16.0.41",
- "machineIdentifier": "elliot-android",
- "platform": "Linux",
- "product": "Plex Web",
- "remotePublicAddress": "203.0.113.24",
- "state": "paused",
- "title": "Iceweasel",
- "local": false,
- "relayed": false,
- "secure": true
- },
- "session": {
- "id": "playback-2",
- "bandwidth": 8400,
- "location": "wan"
- },
- "transcodeSession": {
- "key": "transcode-2"
- },
- "mediaDecision": "transcode"
- },
- {
- "sessionKey": "stream-3",
- "userID": 13,
- "mediaType": "movie",
- "mediaID": "1103",
- "duration": 2700000,
- "viewOffset": 1458000,
- "player": {
- "address": "10.0.0.18",
- "machineIdentifier": "office-safari",
- "platform": "macOS",
- "product": "Plex Web",
- "remotePublicAddress": "198.51.100.73",
- "state": "playing",
- "title": "Safari",
- "local": true,
- "relayed": false,
- "secure": true
- },
- "session": {
- "id": "playback-3",
- "bandwidth": 10200,
- "location": "lan"
- },
- "mediaDecision": "direct play"
- },
- {
- "sessionKey": "stream-4",
- "userID": 15,
- "mediaType": "audiobook",
- "mediaID": "3103",
- "duration": 1314000,
- "viewOffset": 486000,
- "player": {
- "address": "10.20.0.44",
- "machineIdentifier": "tommy-plexamp",
- "platform": "iOS",
- "product": "Prologue",
- "remotePublicAddress": "203.0.113.91",
- "state": "playing",
- "title": "iPhone",
- "local": false,
- "relayed": false,
- "secure": true
- },
- "session": {
- "id": "playback-4",
- "bandwidth": 320,
- "location": "wan"
- },
- "mediaDecision": "direct play",
- "audioStream": {
- "id": 3103001,
- "streamType": 2,
- "codec": "aac",
- "selected": true,
- "levels": [
- -26.9, -28.2, -28.2, -27.9, -29.4, -29.1, -28.5, -28.9,
- -27.8, -28.0, -27.5, -27.7, -27.2, -27.9, -29.0, -27.4,
- -29.2, -29.8, -29.5, -29.2, -29.8, -29.5, -28.7, -27.9,
- -28.2, -28.1, -27.5, -28.1, -28.1, -27.8, -27.3, -27.5,
- -27.7, -27.3, -28.3, -28.0, -28.3, -28.4, -27.4, -26.5,
- -26.6, -26.5, -27.1, -28.1, -27.4, -25.7, -25.5, -25.8,
- -26.7, -26.8, -25.7, -27.1, -27.8, -26.9, -28.8, -28.5,
- -28.4, -27.8, -27.5, -28.0, -27.8, -28.3, -26.2, -26.7,
- -27.6, -26.9, -28.0, -28.1, -27.7, -27.5, -30.3, -29.4,
- -27.2, -21.7, -22.9, -22.1, -21.2, -21.9, -22.0, -23.0,
- -26.6, -27.1, -27.3, -27.9, -27.8, -28.1, -26.2, -27.7,
- -27.6, -27.7, -27.3, -27.2, -27.7, -27.3, -27.5, -39.9
- ]
- }
- }
- ],
- "resolvedLocationsBySessionKey": {
- "stream-1": "Portland, OR",
- "stream-2": "Brooklyn, NY",
- "stream-3": "Lyon, France",
- "stream-4": "Birmingham, UK"
- },
- "historyEvents": [
- {
- "historyKey": "/status/sessions/history/801",
- "userID": 11,
- "mediaType": "movie",
- "mediaID": "1101",
- "viewedAtSecondsAgo": 3600,
- "deviceID": 1
- },
- {
- "historyKey": "/status/sessions/history/802",
- "userID": 12,
- "mediaType": "movie",
- "mediaID": "1102",
- "viewedAtSecondsAgo": 7200,
- "deviceID": 2
- },
- {
- "historyKey": "/status/sessions/history/803",
- "userID": 13,
- "mediaType": "movie",
- "mediaID": "1103",
- "viewedAtSecondsAgo": 43200,
- "deviceID": 3
- },
- {
- "historyKey": "/status/sessions/history/804",
- "userID": 14,
- "mediaType": "movie",
- "mediaID": "1101",
- "viewedAtSecondsAgo": 86400,
- "deviceID": 4
- },
- {
- "historyKey": "/status/sessions/history/805",
- "userID": 15,
- "mediaType": "movie",
- "mediaID": "1102",
- "viewedAtSecondsAgo": 172800,
- "deviceID": 5
- },
- {
- "historyKey": "/status/sessions/history/806",
- "userID": 11,
- "mediaType": "movie",
- "mediaID": "1103",
- "viewedAtSecondsAgo": 345600,
- "deviceID": 1
- },
- {
- "historyKey": "/status/sessions/history/807",
- "userID": 15,
- "mediaType": "episode",
- "mediaID": "2201",
- "viewedAtSecondsAgo": 10800,
- "deviceID": 5
- },
- {
- "historyKey": "/status/sessions/history/808",
- "userID": 14,
- "mediaType": "episode",
- "mediaID": "2202",
- "viewedAtSecondsAgo": 216000,
- "deviceID": 4
- },
- {
- "historyKey": "/status/sessions/history/809",
- "userID": 12,
- "mediaType": "episode",
- "mediaID": "2203",
- "viewedAtSecondsAgo": 259200,
- "deviceID": 2
- },
- {
- "historyKey": "/status/sessions/history/810",
- "userID": 16,
- "mediaType": "episode",
- "mediaID": "2201",
- "viewedAtSecondsAgo": 14400,
- "deviceID": 6
- },
- {
- "historyKey": "/status/sessions/history/811",
- "userID": 16,
- "mediaType": "movie",
- "mediaID": "1102",
- "viewedAtSecondsAgo": 129600,
- "deviceID": 6
- },
- {
- "historyKey": "/status/sessions/history/812",
- "userID": 17,
- "mediaType": "episode",
- "mediaID": "2203",
- "viewedAtSecondsAgo": 1800,
- "deviceID": 7
- },
- {
- "historyKey": "/status/sessions/history/813",
- "userID": 17,
- "mediaType": "movie",
- "mediaID": "1101",
- "viewedAtSecondsAgo": 28800,
- "deviceID": 7
- },
- {
- "historyKey": "/status/sessions/history/814",
- "userID": 17,
- "mediaType": "episode",
- "mediaID": "2202",
- "viewedAtSecondsAgo": 604800,
- "deviceID": 7
- },
- {
- "historyKey": "/status/sessions/history/815",
- "userID": 11,
- "mediaType": "episode",
- "mediaID": "2202",
- "viewedAtSecondsAgo": 93600,
- "deviceID": 1
- },
- {
- "historyKey": "/status/sessions/history/816",
- "userID": 11,
- "mediaType": "movie",
- "mediaID": "1102",
- "viewedAtSecondsAgo": 432000,
- "deviceID": 1
- },
- {
- "historyKey": "/status/sessions/history/817",
- "userID": 12,
- "mediaType": "movie",
- "mediaID": "1101",
- "viewedAtSecondsAgo": 691200,
- "deviceID": 2
- },
- {
- "historyKey": "/status/sessions/history/818",
- "userID": 15,
- "mediaType": "movie",
- "mediaID": "1103",
- "viewedAtSecondsAgo": 777600,
- "deviceID": 5
- }
- ],
- "libraries": [
- {
- "id": "library-movies",
- "title": "Movies",
- "type": "movie",
- "updatedAtSecondsAgo": 10800,
- "scannedAtSecondsAgo": 9600,
- "contentChangedAtSecondsAgo": 7200,
- "entries": [
- {
- "mediaID": "1101",
- "addedAtSecondsAgo": 5400
- },
- {
- "mediaID": "1102",
- "addedAtSecondsAgo": 25200
- },
- {
- "mediaID": "1103",
- "addedAtSecondsAgo": 43200
- }
- ]
- },
- {
- "id": "library-tv-shows",
- "title": "TV Shows",
- "type": "show",
- "updatedAtSecondsAgo": 21600,
- "scannedAtSecondsAgo": 20400,
- "contentChangedAtSecondsAgo": 18000,
- "entries": [
- {
- "mediaID": "2103",
- "addedAtSecondsAgo": 14400
- },
- {
- "mediaID": "2102",
- "addedAtSecondsAgo": 18000
- },
- {
- "mediaID": "2101",
- "addedAtSecondsAgo": 21600
- }
- ],
- "secondarySummary": {
- "queryType": 3,
- "count": 19,
- "label": "seasons"
- }
- },
- {
- "id": "library-audiobooks",
- "title": "Audiobooks",
- "type": "artist",
- "updatedAtSecondsAgo": 36000,
- "scannedAtSecondsAgo": 34200,
- "contentChangedAtSecondsAgo": 28800,
- "entries": [
- {
- "mediaID": "3101",
- "addedAtSecondsAgo": 25200
- },
- {
- "mediaID": "3102",
- "addedAtSecondsAgo": 28800
- },
- {
- "mediaID": "3103",
- "addedAtSecondsAgo": 36000
- }
- ],
- "secondarySummary": {
- "queryType": 9,
- "count": 3,
- "label": "albums"
- }
- }
- ]
-}
diff --git a/Sources/PlexBar/Services/PlexAuthClient.swift b/Sources/PlexBar/Services/PlexAuthClient.swift
deleted file mode 100644
index 661991a..0000000
--- a/Sources/PlexBar/Services/PlexAuthClient.swift
+++ /dev/null
@@ -1,148 +0,0 @@
-import Foundation
-
-struct PlexAuthClient {
- private let session: URLSession
-
- init(session: URLSession = .shared) {
- self.session = session
- }
-
- func fetchAuthenticatedUser(userToken: String, clientContext: PlexClientContext) async throws -> PlexAuthenticatedUser {
- let request = PlexRequestBuilder(clientContext: clientContext).request(
- url: PlexRemoteService.apiURL(path: "/api/v2/user"),
- accept: "application/json",
- token: userToken
- )
-
- let (data, response) = try await session.data(for: request)
- try validate(response: response)
- return try JSONDecoder().decode(PlexAuthenticatedUser.self, from: data)
- }
-
- func createPin(clientContext: PlexClientContext) async throws -> PlexPin {
- let request = PlexRequestBuilder(clientContext: clientContext).request(
- url: PlexRemoteService.apiURL(
- path: "/api/v2/pins",
- queryItems: [URLQueryItem(name: "strong", value: "true")]
- ),
- method: "POST",
- accept: "application/json"
- )
-
- let (data, response) = try await session.data(for: request)
- try validate(response: response)
- return try JSONDecoder().decode(PlexPin.self, from: data)
- }
-
- func fetchPin(id: String, clientContext: PlexClientContext) async throws -> PlexPin {
- let request = PlexRequestBuilder(clientContext: clientContext).request(
- url: PlexRemoteService.apiURL(path: "/api/v2/pins/\(id)"),
- accept: "application/json"
- )
-
- let (data, response) = try await session.data(for: request)
- try validate(response: response)
- return try JSONDecoder().decode(PlexPin.self, from: data)
- }
-
- func fetchServers(userToken: String, clientContext: PlexClientContext) async throws -> [PlexServerResource] {
- let request = PlexRequestBuilder(clientContext: clientContext).request(
- url: PlexRemoteService.apiURL(
- path: "/api/resources",
- queryItems: [
- URLQueryItem(name: "includeHttps", value: "1"),
- URLQueryItem(name: "includeRelay", value: "1"),
- URLQueryItem(name: "includeIPv6", value: "1"),
- ]
- ),
- accept: "application/xml",
- token: userToken
- )
-
- let (data, response) = try await session.data(for: request)
- try validate(response: response)
-
- let document = try XMLDocument(data: data, options: [])
- let devices = try document.nodes(forXPath: "//Device")
-
- return devices.compactMap { node -> PlexServerResource? in
- guard let element = node as? XMLElement else {
- return nil
- }
-
- let provides = element.attribute(forName: "provides")?.stringValue ?? ""
- guard provides.split(separator: ",").contains(where: { $0 == "server" }) else {
- return nil
- }
-
- guard let identifier = element.attribute(forName: "clientIdentifier")?.stringValue?.nilIfBlank,
- let name = element.attribute(forName: "name")?.stringValue?.nilIfBlank,
- let accessToken = element.attribute(forName: "accessToken")?.stringValue?.nilIfBlank else {
- return nil
- }
-
- let productVersion = element.attribute(forName: "productVersion")?.stringValue?.nilIfBlank
-
- let connections = (element.elements(forName: "Connection")).compactMap { connection -> PlexServerConnection? in
- guard let uriString = connection.attribute(forName: "uri")?.stringValue,
- let uri = URL(string: uriString) else {
- return nil
- }
-
- return PlexServerConnection(
- uri: uri,
- local: connection.attribute(forName: "local")?.stringValue == "1",
- relay: connection.attribute(forName: "relay")?.stringValue == "1"
- )
- }
-
- guard !connections.isEmpty else {
- return nil
- }
-
- return PlexServerResource(
- id: identifier,
- name: name,
- productVersion: productVersion,
- accessToken: accessToken,
- connections: connections
- )
- }
- }
-
- private func validate(response: URLResponse) throws {
- guard let httpResponse = response as? HTTPURLResponse else {
- throw PlexAuthError.invalidResponse
- }
-
- guard (200..<300).contains(httpResponse.statusCode) else {
- throw PlexAuthError.badStatusCode(httpResponse.statusCode)
- }
- }
-}
-
-struct PlexPin: Decodable {
- let id: Int
- let code: String
- let authToken: String?
-}
-
-enum PlexAuthError: LocalizedError {
- case invalidAuthURL
- case invalidResponse
- case badStatusCode(Int)
- case noServersFound
-
- var errorDescription: String? {
- switch self {
- case .invalidAuthURL:
- return "PlexBar could not build the Plex sign-in URL."
- case .invalidResponse:
- return "Plex.tv returned a response PlexBar could not read."
- case .badStatusCode(let statusCode):
- return "Plex.tv returned HTTP \(statusCode)."
- case .noServersFound:
- return "No Plex Media Servers were found for this account."
- }
- }
-}
diff --git a/Sources/PlexBar/Services/PlexImageClient.swift b/Sources/PlexBar/Services/PlexImageClient.swift
deleted file mode 100644
index e7af8f8..0000000
--- a/Sources/PlexBar/Services/PlexImageClient.swift
+++ /dev/null
@@ -1,244 +0,0 @@
-import AppKit
-import CoreGraphics
-import Foundation
-import ImageIO
-
-struct PlexFetchedImage {
- let image: NSImage
- let sourceURL: URL
-}
-
-struct PlexFetchedCGImage {
- let image: CGImage
- let sourceURL: URL
-}
-
-struct PlexImageClient {
- private let session: URLSession
- private let cache: PlexImageMemoryCache
-
- init(
- session: URLSession = .shared,
- cache: PlexImageMemoryCache = .shared
- ) {
- self.session = session
- self.cache = cache
- }
-
- func cachedImage(
- from urls: [URL],
- token: String?
- ) -> NSImage? {
- cachedImageResult(from: urls, token: token)?.image
- }
-
- func cachedImageResult(
- from urls: [URL],
- token: String?
- ) -> PlexFetchedImage? {
- for url in urls {
- if let image = cache.image(for: cacheKey(url: url, token: token)) {
- return PlexFetchedImage(image: image, sourceURL: url)
- }
- }
-
- return nil
- }
-
- func cachedPalette(for url: URL, token: String?) -> PlexArtworkPalette? {
- cache.palette(for: cacheKey(url: url, token: token))
- }
-
- func cachePalette(_ palette: PlexArtworkPalette, for url: URL, token: String?) {
- cache.insert(palette, for: cacheKey(url: url, token: token))
- }
-
- func cachedCGImageResult(
- from urls: [URL],
- token: String?
- ) -> PlexFetchedCGImage? {
- for url in urls {
- if let image = cache.cgImage(for: cacheKey(url: url, token: token)) {
- return PlexFetchedCGImage(image: image, sourceURL: url)
- }
- }
-
- return nil
- }
-
- func fetchImage(
- from urls: [URL],
- token: String?,
- clientContext: PlexClientContext
- ) async -> NSImage? {
- await fetchImageResult(
- from: urls,
- token: token,
- clientContext: clientContext
- )?.image
- }
-
- func fetchImageResult(
- from urls: [URL],
- token: String?,
- clientContext: PlexClientContext
- ) async -> PlexFetchedImage? {
- let requestBuilder = PlexRequestBuilder(clientContext: clientContext)
-
- for url in urls {
- let cacheKey = cacheKey(url: url, token: token)
- if let image = cache.image(for: cacheKey) {
- return PlexFetchedImage(image: image, sourceURL: url)
- }
-
- let request = requestBuilder.request(
- url: url,
- accept: "image/*",
- token: token
- )
-
- let data: Data
- let response: URLResponse
-
- do {
- (data, response) = try await session.data(for: request)
- } catch {
- continue
- }
-
- guard let httpResponse = response as? HTTPURLResponse else {
- continue
- }
-
- guard (200..<300).contains(httpResponse.statusCode) else {
- continue
- }
-
- guard let image = NSImage(data: data) else {
- continue
- }
-
- cache.insert(image, for: cacheKey)
- return PlexFetchedImage(image: image, sourceURL: url)
- }
-
- return nil
- }
-
- func fetchCGImageResult(
- from urls: [URL],
- token: String?,
- clientContext: PlexClientContext
- ) async -> PlexFetchedCGImage? {
- let requestBuilder = PlexRequestBuilder(clientContext: clientContext)
-
- for url in urls {
- let cacheKey = cacheKey(url: url, token: token)
- if let image = cache.cgImage(for: cacheKey) {
- return PlexFetchedCGImage(image: image, sourceURL: url)
- }
-
- let request = requestBuilder.request(
- url: url,
- accept: "image/*",
- token: token
- )
-
- let data: Data
- let response: URLResponse
-
- do {
- (data, response) = try await session.data(for: request)
- } catch {
- continue
- }
-
- guard let httpResponse = response as? HTTPURLResponse else {
- continue
- }
-
- guard (200..<300).contains(httpResponse.statusCode) else {
- continue
- }
-
- guard let image = decodeCGImage(from: data) else {
- continue
- }
-
- cache.insert(image, for: cacheKey)
- return PlexFetchedCGImage(image: image, sourceURL: url)
- }
-
- return nil
- }
-
- private func cacheKey(url: URL, token: String?) -> String {
- if let token = token?.nilIfBlank {
- return "\(url.absoluteString)|\(token)"
- }
-
- return url.absoluteString
- }
-
- private func decodeCGImage(from data: Data) -> CGImage? {
- guard let source = CGImageSourceCreateWithData(data as CFData, nil) else {
- return nil
- }
-
- return CGImageSourceCreateImageAtIndex(source, 0, nil)
- }
-}
-
-final class PlexImageMemoryCache: @unchecked Sendable {
- static let shared = PlexImageMemoryCache()
-
- private let cache = NSCache()
- private let cgImageCache = NSCache()
- private let paletteCache = NSCache()
-
- private init() {
- cache.countLimit = 256
- cgImageCache.countLimit = 256
- paletteCache.countLimit = 256
- }
-
- func image(for key: String) -> NSImage? {
- cache.object(forKey: key as NSString)
- }
-
- func insert(_ image: NSImage, for key: String) {
- cache.setObject(image, forKey: key as NSString)
- }
-
- func cgImage(for key: String) -> CGImage? {
- cgImageCache.object(forKey: key as NSString)?.image
- }
-
- func insert(_ image: CGImage, for key: String) {
- cgImageCache.setObject(PlexCGImageBox(image), forKey: key as NSString)
- }
-
- func palette(for key: String) -> PlexArtworkPalette? {
- paletteCache.object(forKey: key as NSString)?.palette
- }
-
- func insert(_ palette: PlexArtworkPalette, for key: String) {
- paletteCache.setObject(PlexArtworkPaletteBox(palette), forKey: key as NSString)
- }
-}
-
-private final class PlexArtworkPaletteBox {
- let palette: PlexArtworkPalette
-
- init(_ palette: PlexArtworkPalette) {
- self.palette = palette
- }
-}
-
-private final class PlexCGImageBox {
- let image: CGImage
-
- init(_ image: CGImage) {
- self.image = image
- }
-}
diff --git a/Sources/PlexBar/Stores/PlexAuthStore.swift b/Sources/PlexBar/Stores/PlexAuthStore.swift
deleted file mode 100644
index d0ebee1..0000000
--- a/Sources/PlexBar/Stores/PlexAuthStore.swift
+++ /dev/null
@@ -1,206 +0,0 @@
-import AppKit
-import Foundation
-import Observation
-
-@MainActor
-@Observable
-final class PlexAuthStore {
- private let settings: PlexSettingsStore
- private let connectionStore: PlexConnectionStore
- private let sessionStore: PlexSessionStore
- private let historyStore: PlexHistoryStore
- private let libraryStore: PlexLibraryStore
- private let client: PlexAuthClient
- private var signInTask: Task?
-
- var authenticatedUser: PlexAuthenticatedUser?
- var availableServers: [PlexServerResource] = []
- var isAuthenticating = false
- var isLoadingAuthenticatedUser = false
- var isLoadingServers = false
- var accountErrorMessage: String?
- var statusMessage: String?
- var errorMessage: String?
- var remainingSeconds: Int?
-
- init(
- settings: PlexSettingsStore,
- connectionStore: PlexConnectionStore,
- sessionStore: PlexSessionStore,
- historyStore: PlexHistoryStore,
- libraryStore: PlexLibraryStore,
- client: PlexAuthClient = PlexAuthClient()
- ) {
- self.settings = settings
- self.connectionStore = connectionStore
- self.sessionStore = sessionStore
- self.historyStore = historyStore
- self.libraryStore = libraryStore
- self.client = client
-
- if settings.hasAuthenticatedAccount {
- Task {
- await refreshAuthenticatedState(autoSelectStoredServer: true)
- }
- }
- }
-
- func refreshAuthenticatedUser() async {
- guard settings.hasAuthenticatedAccount else {
- authenticatedUser = nil
- accountErrorMessage = nil
- isLoadingAuthenticatedUser = false
- return
- }
-
- isLoadingAuthenticatedUser = true
- accountErrorMessage = nil
-
- let clientContext = PlexClientContext(clientIdentifier: settings.clientIdentifier)
-
- do {
- authenticatedUser = try await client.fetchAuthenticatedUser(
- userToken: settings.trimmedUserToken,
- clientContext: clientContext
- )
- } catch {
- authenticatedUser = nil
- accountErrorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
- }
-
- isLoadingAuthenticatedUser = false
- }
-
- func startSignIn() {
- guard !isAuthenticating else {
- return
- }
-
- signInTask?.cancel()
- let clientIdentifier = settings.rotateClientIdentifier()
- signInTask = Task {
- await runSignIn(clientIdentifier: clientIdentifier)
- }
- }
-
- func refreshServers(autoSelectStoredServer: Bool = false) async {
- guard settings.hasAuthenticatedAccount else {
- availableServers = []
- return
- }
-
- isLoadingServers = true
- errorMessage = nil
-
- let clientContext = PlexClientContext(clientIdentifier: settings.clientIdentifier)
-
- do {
- let servers = try await client.fetchServers(
- userToken: settings.trimmedUserToken,
- clientContext: clientContext
- )
-
- guard !servers.isEmpty else {
- throw PlexAuthError.noServersFound
- }
-
- availableServers = servers
- connectionStore.updateAvailableServers(servers)
-
- if autoSelectStoredServer,
- let selectedServerIdentifier = settings.selectedServerIdentifier,
- let storedServer = servers.first(where: { $0.id == selectedServerIdentifier }) {
- selectServer(storedServer)
- } else if settings.selectedServerIdentifier == nil || !servers.contains(where: { $0.id == settings.selectedServerIdentifier }) {
- selectServer(servers[0])
- }
-
- statusMessage = nil
- } catch {
- errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
- }
-
- isLoadingServers = false
- }
-
- func selectServer(withID serverID: String) {
- guard let server = availableServers.first(where: { $0.id == serverID }) else {
- return
- }
-
- selectServer(server)
- }
-
- func signOut() {
- signInTask?.cancel()
- isAuthenticating = false
- isLoadingAuthenticatedUser = false
- isLoadingServers = false
- authenticatedUser = nil
- accountErrorMessage = nil
- statusMessage = nil
- errorMessage = nil
- remainingSeconds = nil
- availableServers = []
- settings.clearAuthentication()
- connectionStore.updateAvailableServers([])
- sessionStore.didChangeConfiguration()
- historyStore.refreshNow()
- }
-
- private func selectServer(_ server: PlexServerResource) {
- settings.saveServerSelection(server)
- connectionStore.didSelectServer()
- sessionStore.didChangeConfiguration()
- historyStore.refreshNow()
- }
-
- private func refreshAuthenticatedState(autoSelectStoredServer: Bool) async {
- async let authenticatedUserRefresh: Void = refreshAuthenticatedUser()
- async let serverRefresh: Void = refreshServers(autoSelectStoredServer: autoSelectStoredServer)
- _ = await (authenticatedUserRefresh, serverRefresh)
- }
-
- private func runSignIn(clientIdentifier: String) async {
- isAuthenticating = true
- statusMessage = "Waiting for authentication in your browser…"
- errorMessage = nil
- remainingSeconds = nil
-
- let clientContext = PlexClientContext(clientIdentifier: clientIdentifier)
-
- do {
- let pin = try await client.createPin(clientContext: clientContext)
- guard let authURL = clientContext.authURL(for: pin.code) else {
- throw PlexAuthError.invalidAuthURL
- }
-
- NSWorkspace.shared.open(authURL)
-
- for seconds in stride(from: 120, through: 1, by: -1) {
- remainingSeconds = seconds
-
- let currentPin = try await client.fetchPin(id: String(pin.id), clientContext: clientContext)
- if let authToken = currentPin.authToken?.nilIfBlank {
- settings.saveAuthenticatedUserToken(authToken)
- statusMessage = "Authentication successful."
- remainingSeconds = nil
- isAuthenticating = false
- await refreshAuthenticatedState(autoSelectStoredServer: true)
- return
- }
-
- try await Task.sleep(for: .seconds(1))
- }
-
- statusMessage = nil
- errorMessage = "Authentication timed out. Please try again."
- } catch {
- statusMessage = nil
- errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
- }
-
- remainingSeconds = nil
- isAuthenticating = false
- }
-}
diff --git a/Sources/PlexBar/Stores/PlexHistoryStore.swift b/Sources/PlexBar/Stores/PlexHistoryStore.swift
deleted file mode 100644
index 8ac4c19..0000000
--- a/Sources/PlexBar/Stores/PlexHistoryStore.swift
+++ /dev/null
@@ -1,149 +0,0 @@
-import Foundation
-import Observation
-
-@MainActor
-@Observable
-final class PlexHistoryStore {
- static let historyWindowDays = 30
-
- private let connectionStore: PlexConnectionStore
- private let libraryStore: PlexLibraryStore
- private let client: PlexAPIClient
- private var pollingTask: Task?
-
- var recentItems: [PlexHistoryItem] = []
- var seriesByEpisodeID: [String: PlexHistorySeriesIdentity] = [:]
- var accountsByID: [Int: PlexAccount] = [:]
- var isLoading = false
- var errorMessage: String?
- var lastUpdated: Date?
-
- init(
- connectionStore: PlexConnectionStore,
- libraryStore: PlexLibraryStore,
- client: PlexAPIClient = PlexAPIClient()
- ) {
- self.connectionStore = connectionStore
- self.libraryStore = libraryStore
- self.client = client
- startPolling()
- }
-
- var topTitleEntries: [PlexTopChartEntry] {
- PlexHistoryAnalytics.topTitleEntries(
- from: recentItems,
- accountsByID: accountsByID,
- seriesByEpisodeID: seriesByEpisodeID,
- limit: 5
- )
- }
-
- var topTypeEntries: [PlexTopChartEntry] {
- PlexHistoryAnalytics.topTypeEntries(from: recentItems, accountsByID: accountsByID, limit: 4)
- }
-
- var topUserEntries: [PlexUserActivityEntry] {
- PlexHistoryAnalytics.topUserEntries(from: recentItems, accountsByID: accountsByID, limit: 5)
- }
-
- var recentViewerEntries: [PlexUserActivityEntry] {
- PlexHistoryAnalytics.recentViewerEntries(from: recentItems, accountsByID: accountsByID, limit: 6)
- }
-
- var distinctViewerCount: Int {
- Set(recentItems.compactMap(\.accountID)).count
- }
-
- var totalPlayCount: Int {
- recentItems.count
- }
-
- var historyWindowLabel: String {
- "Last \(Self.historyWindowDays) days"
- }
-
- func refreshNow() {
- Task {
- await refresh()
- libraryStore.refreshNow()
- }
- }
-
- func restartPolling() {
- pollingTask?.cancel()
- pollingTask = nil
- startPolling()
- }
-
- private func startPolling() {
- guard pollingTask == nil else {
- return
- }
-
- let pollIntervalDuration = connectionStore.settings.historyPollIntervalDuration
-
- pollingTask = Task { [weak self] in
- while !Task.isCancelled {
- guard let self else {
- return
- }
-
- await self.refresh()
- self.libraryStore.refreshNow()
-
- do {
- try await Task.sleep(for: pollIntervalDuration)
- } catch {
- return
- }
- }
- }
- }
-
- private func refresh() async {
- guard connectionStore.settings.hasValidConfiguration else {
- recentItems = []
- seriesByEpisodeID = [:]
- accountsByID = [:]
- errorMessage = nil
- isLoading = false
- return
- }
-
- isLoading = true
-
- do {
- let cutoffDate = Calendar.current.date(byAdding: .day, value: -Self.historyWindowDays, to: Date()) ?? Date.distantPast
- let result = try await connectionStore.perform { configuration in
- async let historyTask = client.fetchHistory(using: configuration, since: cutoffDate)
- async let accountsTask = client.fetchAccounts(using: configuration)
-
- let rawHistoryItems = try await historyTask
- let seriesByEpisodeID = try await client.fetchHistorySeriesIdentities(
- using: configuration,
- episodeIDs: rawHistoryItems.compactMap(\.episodeMetadataItemID)
- )
-
- let accounts: [PlexAccount]
- do {
- accounts = try await accountsTask
- } catch {
- accounts = []
- }
-
- return (rawHistoryItems, seriesByEpisodeID, accounts)
- }
-
- self.recentItems = PlexHistoryAnalytics.groupedWatchItems(from: result.0)
- self.seriesByEpisodeID = result.1
- self.accountsByID = Dictionary(uniqueKeysWithValues: result.2.map { ($0.id, $0) })
-
- errorMessage = nil
- lastUpdated = Date()
- } catch {
- errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
- }
-
- isLoading = false
- }
-}
diff --git a/Sources/PlexBar/Stores/PlexSettingsStore.swift b/Sources/PlexBar/Stores/PlexSettingsStore.swift
deleted file mode 100644
index ec72903..0000000
--- a/Sources/PlexBar/Stores/PlexSettingsStore.swift
+++ /dev/null
@@ -1,295 +0,0 @@
-import Foundation
-import Observation
-
-@MainActor
-@Observable
-final class PlexSettingsStore {
- private enum DefaultsKeys {
- static let installIdentifier = "plex.installIdentifier"
- static let cachedConnectionURL = "plex.serverURL"
- static let cachedConnectionKind = "plex.cachedConnectionKind"
- static let clientIdentifier = "plex.clientIdentifier"
- static let selectedServerIdentifier = "plex.selectedServerIdentifier"
- static let selectedServerName = "plex.selectedServerName"
- static let connectionRecheckIntervalSeconds = "plex.connectionRecheckIntervalSeconds"
- static let historyPollIntervalSeconds = "plex.historyPollIntervalSeconds"
- }
-
- private let defaults: UserDefaults
- private let keychain: KeychainStore
- private let loginItemService: any PlexLoginItemControlling
-
- var cachedConnectionURLString: String {
- didSet {
- defaults.set(cachedConnectionURLString, forKey: DefaultsKeys.cachedConnectionURL)
- }
- }
-
- var cachedConnectionKind: PlexConnectionKind? {
- didSet {
- defaults.set(cachedConnectionKind?.rawValue, forKey: DefaultsKeys.cachedConnectionKind)
- }
- }
-
- var selectedServerIdentifier: String? {
- didSet {
- defaults.set(selectedServerIdentifier, forKey: DefaultsKeys.selectedServerIdentifier)
- }
- }
-
- var selectedServerName: String? {
- didSet {
- defaults.set(selectedServerName, forKey: DefaultsKeys.selectedServerName)
- }
- }
-
- var userToken: String {
- didSet {
- persistUserToken()
- }
- }
-
- var serverToken: String {
- didSet {
- persistServerToken()
- }
- }
-
- var connectionRecheckIntervalSeconds: Int {
- didSet {
- let normalizedValue = Self.normalizedConnectionRecheckIntervalSeconds(connectionRecheckIntervalSeconds)
- if connectionRecheckIntervalSeconds != normalizedValue {
- connectionRecheckIntervalSeconds = normalizedValue
- return
- }
-
- defaults.set(normalizedValue, forKey: DefaultsKeys.connectionRecheckIntervalSeconds)
- }
- }
-
- var historyPollIntervalSeconds: Int {
- didSet {
- let normalizedValue = Self.normalizedHistoryPollIntervalSeconds(historyPollIntervalSeconds)
- if historyPollIntervalSeconds != normalizedValue {
- historyPollIntervalSeconds = normalizedValue
- return
- }
-
- defaults.set(normalizedValue, forKey: DefaultsKeys.historyPollIntervalSeconds)
- }
- }
-
- private(set) var clientIdentifier: String {
- didSet {
- defaults.set(clientIdentifier, forKey: DefaultsKeys.clientIdentifier)
- }
- }
-
- private(set) var openAtLoginStatus: PlexLoginItemStatus
- var openAtLoginErrorMessage: String?
-
- init(
- defaults: UserDefaults = .standard,
- keychain: KeychainStore = KeychainStore(service: AppConstants.bundleIdentifier),
- loginItemService: any PlexLoginItemControlling = PlexLoginItemService()
- ) {
- self.defaults = defaults
- self.keychain = keychain
- self.loginItemService = loginItemService
- cachedConnectionURLString = defaults.string(forKey: DefaultsKeys.cachedConnectionURL) ?? ""
- cachedConnectionKind = defaults.string(forKey: DefaultsKeys.cachedConnectionKind).flatMap(PlexConnectionKind.init(rawValue:))
-
- let installIdentifier = Self.loadInstallIdentifier(from: defaults)
- clientIdentifier = Self.loadClientIdentifier(from: defaults, installIdentifier: installIdentifier)
-
- selectedServerIdentifier = defaults.string(forKey: DefaultsKeys.selectedServerIdentifier)
- selectedServerName = defaults.string(forKey: DefaultsKeys.selectedServerName)
- userToken = keychain.read(account: KeychainAccounts.userToken) ?? ""
- serverToken = keychain.read(account: KeychainAccounts.serverToken) ?? ""
- connectionRecheckIntervalSeconds = Self.normalizedConnectionRecheckIntervalSeconds(
- defaults.object(forKey: DefaultsKeys.connectionRecheckIntervalSeconds) as? Int ?? AppConstants.defaultConnectionRecheckIntervalSeconds
- )
- historyPollIntervalSeconds = Self.normalizedHistoryPollIntervalSeconds(
- defaults.object(forKey: DefaultsKeys.historyPollIntervalSeconds) as? Int ?? AppConstants.defaultHistoryPollIntervalSeconds
- )
- openAtLoginStatus = loginItemService.status()
- openAtLoginErrorMessage = nil
- }
-
- var normalizedServerURL: URL? {
- PlexURLBuilder.normalizeServerURL(cachedConnectionURLString)
- }
-
- var trimmedUserToken: String {
- userToken.trimmingCharacters(in: .whitespacesAndNewlines)
- }
-
- var trimmedServerToken: String {
- serverToken.trimmingCharacters(in: .whitespacesAndNewlines)
- }
-
- var hasValidConfiguration: Bool {
- selectedServerIdentifier?.nilIfBlank != nil && !trimmedServerToken.isEmpty
- }
-
- var hasAuthenticatedAccount: Bool {
- !trimmedUserToken.isEmpty
- }
-
- var connectionRecheckIntervalDuration: Duration? {
- guard connectionRecheckIntervalSeconds > 0 else {
- return nil
- }
-
- return .seconds(connectionRecheckIntervalSeconds)
- }
-
- var historyPollIntervalDuration: Duration {
- return .seconds(historyPollIntervalSeconds)
- }
-
- var opensAtLogin: Bool {
- switch openAtLoginStatus {
- case .enabled, .requiresApproval:
- return true
- case .notRegistered, .notFound:
- return false
- }
- }
-
- var openAtLoginRequiresApproval: Bool {
- openAtLoginStatus == .requiresApproval
- }
-
- func saveAuthenticatedUserToken(_ token: String) {
- userToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
- }
-
- func saveServerSelection(_ server: PlexServerResource) {
- selectedServerIdentifier = server.id
- selectedServerName = server.name
- serverToken = server.accessToken
- clearCachedConnection()
- }
-
- func saveResolvedConnection(_ connection: PlexResolvedConnection) {
- cachedConnectionURLString = connection.url.absoluteString
- cachedConnectionKind = connection.kind
- }
-
- func clearCachedConnection() {
- cachedConnectionURLString = ""
- cachedConnectionKind = nil
- }
-
- func clearAuthentication() {
- selectedServerIdentifier = nil
- selectedServerName = nil
- clearCachedConnection()
- userToken = ""
- serverToken = ""
- }
-
- func refreshOpenAtLoginStatus() {
- openAtLoginStatus = loginItemService.status()
- openAtLoginErrorMessage = nil
- }
-
- func setOpenAtLogin(_ enabled: Bool) {
- openAtLoginErrorMessage = nil
-
- do {
- try loginItemService.setEnabled(enabled)
- refreshOpenAtLoginStatus()
-
- if enabled && openAtLoginStatus == .notFound {
- openAtLoginErrorMessage = "PlexBar could not register itself as a login item."
- }
- } catch {
- refreshOpenAtLoginStatus()
- openAtLoginErrorMessage = openAtLoginActionErrorMessage(for: enabled, error: error)
- }
- }
-
- func openLoginItemsSystemSettings() {
- loginItemService.openSystemSettingsLoginItems()
- }
-
- @discardableResult
- func rotateClientIdentifier() -> String {
- let newIdentifier = Self.newIdentifier()
- clientIdentifier = newIdentifier
- return newIdentifier
- }
-
- private func persistUserToken() {
- let trimmedToken = trimmedUserToken
-
- if trimmedToken.isEmpty {
- keychain.delete(account: KeychainAccounts.userToken)
- return
- }
-
- keychain.write(trimmedToken, account: KeychainAccounts.userToken)
- }
-
- private func persistServerToken() {
- let trimmedToken = trimmedServerToken
-
- if trimmedToken.isEmpty {
- keychain.delete(account: KeychainAccounts.serverToken)
- return
- }
-
- keychain.write(trimmedToken, account: KeychainAccounts.serverToken)
- }
-
- private func openAtLoginActionErrorMessage(for enabled: Bool, error: Error) -> String {
- let description = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
-
- if enabled {
- return "PlexBar could not enable Open at Login. \(description)"
- }
-
- return "PlexBar could not disable Open at Login. \(description)"
- }
-
- private static func normalizedConnectionRecheckIntervalSeconds(_ value: Int) -> Int {
- guard AppConstants.allowedConnectionRecheckIntervalSeconds.contains(value) else {
- return AppConstants.defaultConnectionRecheckIntervalSeconds
- }
-
- return value
- }
-
- private static func normalizedHistoryPollIntervalSeconds(_ value: Int) -> Int {
- guard AppConstants.allowedHistoryPollIntervalSeconds.contains(value) else {
- return AppConstants.defaultHistoryPollIntervalSeconds
- }
-
- return value
- }
-
- private static func loadInstallIdentifier(from defaults: UserDefaults) -> String {
- if let existingInstallIdentifier = defaults.string(forKey: DefaultsKeys.installIdentifier)?.nilIfBlank {
- return existingInstallIdentifier
- }
-
- let installIdentifier = defaults.string(forKey: DefaultsKeys.clientIdentifier)?.nilIfBlank ?? newIdentifier()
- defaults.set(installIdentifier, forKey: DefaultsKeys.installIdentifier)
- return installIdentifier
- }
-
- private static func loadClientIdentifier(from defaults: UserDefaults, installIdentifier: String) -> String {
- if let existingClientIdentifier = defaults.string(forKey: DefaultsKeys.clientIdentifier)?.nilIfBlank {
- return existingClientIdentifier
- }
-
- defaults.set(installIdentifier, forKey: DefaultsKeys.clientIdentifier)
- return installIdentifier
- }
-
- private static func newIdentifier() -> String {
- UUID().uuidString
- }
-}
diff --git a/Sources/PlexBar/Support/AppConstants.swift b/Sources/PlexBar/Support/AppConstants.swift
deleted file mode 100644
index c025ba8..0000000
--- a/Sources/PlexBar/Support/AppConstants.swift
+++ /dev/null
@@ -1,16 +0,0 @@
-import Foundation
-
-enum AppConstants {
- static let appName = "PlexBar"
- static let bundleIdentifier = "com.crapshack.PlexBar"
- static let productVersion = "0.8.0"
- static let defaultConnectionRecheckIntervalSeconds = 900
- static let allowedConnectionRecheckIntervalSeconds = [0, 300, 900, 1_800, 3_600]
- static let defaultHistoryPollIntervalSeconds = 900
- static let allowedHistoryPollIntervalSeconds = [900, 3_600, 86_400]
-}
-
-enum KeychainAccounts {
- static let userToken = "plex-user-token"
- static let serverToken = "plex-server-token"
-}
diff --git a/Sources/PlexBar/Support/KeychainStore.swift b/Sources/PlexBar/Support/KeychainStore.swift
deleted file mode 100644
index 95e668b..0000000
--- a/Sources/PlexBar/Support/KeychainStore.swift
+++ /dev/null
@@ -1,49 +0,0 @@
-import Foundation
-import Security
-
-struct KeychainStore {
- let service: String
-
- func read(account: String) -> String? {
- let query = baseQuery(account: account)
-
- var item: CFTypeRef?
- let status = SecItemCopyMatching(query as CFDictionary, &item)
-
- guard status == errSecSuccess,
- let data = item as? Data,
- let string = String(data: data, encoding: .utf8) else {
- return nil
- }
-
- return string
- }
-
- func write(_ value: String, account: String) {
- let data = Data(value.utf8)
- let query = baseQuery(account: account)
- let attributes = [kSecValueData as String: data]
-
- let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
-
- if updateStatus == errSecItemNotFound {
- var createQuery = query
- createQuery[kSecValueData as String] = data
- SecItemAdd(createQuery as CFDictionary, nil)
- }
- }
-
- func delete(account: String) {
- SecItemDelete(baseQuery(account: account) as CFDictionary)
- }
-
- private func baseQuery(account: String) -> [String: Any] {
- [
- kSecClass as String: kSecClassGenericPassword,
- kSecAttrService as String: service,
- kSecAttrAccount as String: account,
- kSecMatchLimit as String: kSecMatchLimitOne,
- kSecReturnData as String: true
- ]
- }
-}
diff --git a/Sources/PlexBar/Support/MenuBarIcon.swift b/Sources/PlexBar/Support/MenuBarIcon.swift
deleted file mode 100644
index 0c7e757..0000000
--- a/Sources/PlexBar/Support/MenuBarIcon.swift
+++ /dev/null
@@ -1,47 +0,0 @@
-import AppKit
-
-@MainActor
-enum MenuBarIcon {
- private static let resourceBundleName = "PlexBar_PlexBar.bundle"
-
- private static let resourceBundle: Bundle = {
- guard
- let resourceURL = Bundle.main.resourceURL,
- let bundle = Bundle(url: resourceURL.appendingPathComponent(resourceBundleName))
- else {
- preconditionFailure("Missing resource bundle \(resourceBundleName)")
- }
-
- return bundle
- }()
-
- static let image: NSImage = {
- let image = NSImage(size: NSSize(width: 16, height: 16))
-
- for resourceName in ["MenuBarIcon.png", "MenuBarIcon@2x.png"] {
- guard let url = resourceBundle.url(forResource: resourceName, withExtension: nil),
- let data = try? Data(contentsOf: url),
- let representation = NSBitmapImageRep(data: data)
- else {
- continue
- }
-
- if resourceName.contains("@2x") {
- representation.size = NSSize(
- width: CGFloat(representation.pixelsWide) / 2,
- height: CGFloat(representation.pixelsHigh) / 2
- )
- } else {
- representation.size = NSSize(
- width: CGFloat(representation.pixelsWide),
- height: CGFloat(representation.pixelsHigh)
- )
- }
-
- image.addRepresentation(representation)
- }
-
- image.isTemplate = true
- return image
- }()
-}
diff --git a/Sources/PlexBar/Support/PlexAppRuntime.swift b/Sources/PlexBar/Support/PlexAppRuntime.swift
deleted file mode 100644
index 47b9518..0000000
--- a/Sources/PlexBar/Support/PlexAppRuntime.swift
+++ /dev/null
@@ -1,91 +0,0 @@
-import Foundation
-
-@MainActor
-struct PlexAppRuntime {
- enum Mode: Equatable {
- case live
- case mock
- }
-
- private static let mockArgument = "--mock"
- private static let mockDefaultsSuiteName = "\(AppConstants.bundleIdentifier).mock"
- private static let mockKeychainService = "\(AppConstants.bundleIdentifier).mock"
-
- let settingsStore: PlexSettingsStore
- let authClient: PlexAuthClient
- let apiClient: PlexAPIClient
- let geoIPClient: PlexGeoIPClient
- let sessionEventsClient: PlexSessionEventsClient
- let connectionResolver: PlexConnectionResolver
-
- static func current(processInfo: ProcessInfo = .processInfo) -> PlexAppRuntime {
- current(arguments: processInfo.arguments)
- }
-
- static func current(arguments: [String]) -> PlexAppRuntime {
- switch mode(arguments: arguments) {
- case .live:
- return liveRuntime()
- case .mock:
- return mockRuntime()
- }
- }
-
- static func mode(arguments: [String]) -> Mode {
- #if DEBUG
- if arguments.contains(mockArgument) {
- return .mock
- }
- #else
- _ = arguments
- #endif
-
- return .live
- }
-
- private static func liveRuntime() -> PlexAppRuntime {
- let settingsStore = PlexSettingsStore()
- let authClient = PlexAuthClient()
- let apiClient = PlexAPIClient()
- let geoIPClient = PlexGeoIPClient()
- let sessionEventsClient = PlexSessionEventsClient()
-
- return PlexAppRuntime(
- settingsStore: settingsStore,
- authClient: authClient,
- apiClient: apiClient,
- geoIPClient: geoIPClient,
- sessionEventsClient: sessionEventsClient,
- connectionResolver: PlexConnectionResolver(client: apiClient)
- )
- }
-
- private static func mockRuntime() -> PlexAppRuntime {
- let settingsStore = mockSettingsStore()
- let session = PlexDebugMockServer.makeSession()
- let apiClient = PlexAPIClient(session: session)
-
- return PlexAppRuntime(
- settingsStore: settingsStore,
- authClient: PlexAuthClient(session: session),
- apiClient: apiClient,
- geoIPClient: PlexGeoIPClient(session: session),
- sessionEventsClient: PlexDebugMockServer.makeEventsClient(),
- connectionResolver: PlexConnectionResolver(client: apiClient)
- )
- }
-
- private static func mockSettingsStore() -> PlexSettingsStore {
- let defaults = UserDefaults(suiteName: mockDefaultsSuiteName) ?? .standard
- defaults.removePersistentDomain(forName: mockDefaultsSuiteName)
-
- let settingsStore = PlexSettingsStore(
- defaults: defaults,
- keychain: KeychainStore(service: mockKeychainService)
- )
- settingsStore.saveAuthenticatedUserToken(PlexDebugMockServer.mockUserToken)
- settingsStore.saveServerSelection(PlexDebugMockServer.mockServer)
- settingsStore.saveResolvedConnection(PlexDebugMockServer.mockResolvedConnection)
- return settingsStore
- }
-}
diff --git a/Sources/PlexBar/Support/PlexDebugMockServer.swift b/Sources/PlexBar/Support/PlexDebugMockServer.swift
deleted file mode 100644
index 02f1ed9..0000000
--- a/Sources/PlexBar/Support/PlexDebugMockServer.swift
+++ /dev/null
@@ -1,1254 +0,0 @@
-import AppKit
-import Foundation
-
-enum PlexDebugMockServer {
- static var mockUserToken: String {
- #if DEBUG
- return debugFixture.userToken
- #else
- preconditionFailure("Mock runtime is only available in DEBUG builds.")
- #endif
- }
-
- static var mockServer: PlexServerResource {
- #if DEBUG
- return debugFixture.server
- #else
- preconditionFailure("Mock runtime is only available in DEBUG builds.")
- #endif
- }
-
- static var mockResolvedConnection: PlexResolvedConnection {
- #if DEBUG
- return debugFixture.activeConnection
- #else
- preconditionFailure("Mock runtime is only available in DEBUG builds.")
- #endif
- }
-
- static func makeSession() -> URLSession {
- #if DEBUG
- debugFixture.seedArtworkCache()
- let stateID = PlexDebugMockStateRegistry.shared.register(PlexDebugMockState())
- let configuration = URLSessionConfiguration.ephemeral
- configuration.httpAdditionalHeaders = [PlexDebugMockStateRegistry.headerName: stateID]
- configuration.protocolClasses = [PlexDebugMockURLProtocol.self]
- return URLSession(configuration: configuration)
- #else
- return .shared
- #endif
- }
-
- static func makeEventsClient(liveClient: PlexSessionEventsClient = PlexSessionEventsClient()) -> PlexSessionEventsClient {
- #if DEBUG
- return PlexSessionEventsClient { configuration, onEvent in
- guard configuration.serverURL.host == debugFixture.server.connections[0].uri.host else {
- try await liveClient.monitor(using: configuration, onEvent: onEvent)
- return
- }
-
- try await onEvent(.connected)
-
- while !Task.isCancelled {
- try await Task.sleep(for: .seconds(3_600))
- }
-
- throw CancellationError()
- }
- #else
- return liveClient
- #endif
- }
-
-}
-
-#if DEBUG
-private let debugFixture = PlexDebugMockFixture.makeDefault()
-
-private struct PlexDebugMockFixture {
- let userToken: String
- let authenticatedUser: PlexAuthenticatedUser
- let server: PlexServerResource
- let activeConnection: PlexResolvedConnection
- let sessions: [PlexSession]
- let streamLevelsByID: [Int: [Double]]
- let resolvedLocationsBySessionKey: [String: String]
- let historyItems: [PlexHistoryItem]
- let metadataItems: [PlexMetadataItem]
- let accountsByID: [Int: PlexAccount]
- let librarySections: [PlexDebugMockLibrarySection]
- let snapshotDate: Date
- let seededArtwork: [DebugSeededArtwork]
-
- var libraries: [PlexLibrary] {
- librarySections.map(\.library)
- }
-
- static func makeDefault() -> PlexDebugMockFixture {
- let payload = try! PlexMockServerPayload.loadDefault()
- let snapshotDate = Date()
- let userToken = "plexbar-debug-mock-user-token"
- let server = payload.server.materialize()
- let serverURL = server.connections[0].uri
- let usersByID = Dictionary(uniqueKeysWithValues: payload.users.map { ($0.id, $0) })
- let moviesByID = Dictionary(uniqueKeysWithValues: payload.movies.map { ($0.id, $0) })
- let showsByID = Dictionary(uniqueKeysWithValues: payload.shows.map { ($0.id, $0) })
- let episodesByID = Dictionary(uniqueKeysWithValues: payload.episodes.map { ($0.id, $0) })
- let audiobooksByID = Dictionary(uniqueKeysWithValues: payload.audiobooks.map { ($0.id, $0) })
-
- let activeConnection = PlexResolvedConnection(
- serverID: server.id,
- url: serverURL,
- kind: .local,
- validatedAt: snapshotDate
- )
- let authenticatedUser = payload.authenticatedUser.materialize(
- thumbOverride: localAvatarResourceURL(for: payload.authenticatedUser.thumb)?.absoluteString
- )
-
- let accountsByID = Dictionary(
- uniqueKeysWithValues: payload.users.map { userPayload in
- let account = userPayload.materialize()
- return (account.id, account)
- }
- )
- let sessions = payload.activeSessions.map {
- materializeSession(
- $0,
- usersByID: usersByID,
- moviesByID: moviesByID,
- showsByID: showsByID,
- episodesByID: episodesByID,
- audiobooksByID: audiobooksByID
- )
- }
- let historyItems = payload.historyEvents.map {
- materializeHistoryItem(
- $0,
- referenceDate: snapshotDate,
- moviesByID: moviesByID,
- showsByID: showsByID,
- episodesByID: episodesByID
- )
- }
- let metadataItems = payload.episodes.map { materializeMetadataItem($0, showsByID: showsByID) }
- let librarySections = payload.libraries.map {
- materializeLibrarySection(
- $0,
- referenceDate: snapshotDate,
- moviesByID: moviesByID,
- showsByID: showsByID,
- audiobooksByID: audiobooksByID
- )
- }
-
- return PlexDebugMockFixture(
- userToken: userToken,
- authenticatedUser: authenticatedUser,
- server: server,
- activeConnection: activeConnection,
- sessions: sessions,
- streamLevelsByID: Dictionary(
- payload.activeSessions.compactMap { session in
- session.audioStream.map { ($0.id, $0.levels) }
- },
- uniquingKeysWith: { existing, _ in existing }
- ),
- resolvedLocationsBySessionKey: payload.resolvedLocationsBySessionKey,
- historyItems: historyItems,
- metadataItems: metadataItems,
- accountsByID: accountsByID,
- librarySections: librarySections,
- snapshotDate: snapshotDate,
- seededArtwork: [
- DebugSeededArtwork.load(serverURL: serverURL, mockPath: "/mock/avatars/dana-scully.png", sourceFileName: "dana-scully.png"),
- DebugSeededArtwork.load(serverURL: serverURL, mockPath: "/mock/avatars/darlene-alderson.png", sourceFileName: "darlene-alderson.png"),
- DebugSeededArtwork.load(serverURL: serverURL, mockPath: "/mock/avatars/elliot-alderson.png", sourceFileName: "elliot-alderson.png"),
- DebugSeededArtwork.load(serverURL: serverURL, mockPath: "/mock/avatars/le-petit-prince.png", sourceFileName: "le-petit-prince.png"),
- DebugSeededArtwork.load(serverURL: serverURL, mockPath: "/mock/avatars/popeye.png", sourceFileName: "popeye.png"),
- DebugSeededArtwork.load(serverURL: serverURL, mockPath: "/mock/avatars/scrump-toggins.png", sourceFileName: "scrump-toggins.png"),
- DebugSeededArtwork.load(serverURL: serverURL, mockPath: "/mock/avatars/tommy-shelby.png", sourceFileName: "tommy-shelby.png"),
- DebugSeededArtwork.load(serverURL: serverURL, mockPath: "/mock/art/movies/charade.png", sourceFileName: "charade.png", resourceDirectory: "Resources/MockServer/art/movies"),
- DebugSeededArtwork.load(serverURL: serverURL, mockPath: "/mock/art/movies/night-of-the-living-dead.png", sourceFileName: "night-of-the-living-dead.png", resourceDirectory: "Resources/MockServer/art/movies"),
- DebugSeededArtwork.load(serverURL: serverURL, mockPath: "/mock/art/movies/sherlock-jr.png", sourceFileName: "sherlock-jr.png", resourceDirectory: "Resources/MockServer/art/movies"),
- DebugSeededArtwork.load(serverURL: serverURL, mockPath: "/mock/art/tv/one-step-beyond.png", sourceFileName: "one-step-beyond.png", resourceDirectory: "Resources/MockServer/art/tv"),
- DebugSeededArtwork.load(serverURL: serverURL, mockPath: "/mock/art/tv/adventures-of-ozzie-and-harriet.png", sourceFileName: "adventures-of-ozzie-and-harriet.png", resourceDirectory: "Resources/MockServer/art/tv"),
- DebugSeededArtwork.load(serverURL: serverURL, mockPath: "/mock/art/tv/abbott-and-costello.png", sourceFileName: "abbott-and-costello.png", resourceDirectory: "Resources/MockServer/art/tv"),
- DebugSeededArtwork.load(serverURL: serverURL, mockPath: "/mock/art/audiobooks/dracula.png", sourceFileName: "dracula.png", resourceDirectory: "Resources/MockServer/art/audiobooks"),
- DebugSeededArtwork.load(serverURL: serverURL, mockPath: "/mock/art/audiobooks/the-time-machine.png", sourceFileName: "the-time-machine.png", resourceDirectory: "Resources/MockServer/art/audiobooks"),
- DebugSeededArtwork.load(serverURL: serverURL, mockPath: "/mock/art/audiobooks/war-of-the-worlds.png", sourceFileName: "war-of-the-worlds.png", resourceDirectory: "Resources/MockServer/art/audiobooks"),
- ]
- )
- }
-
- func seedArtworkCache() {
- let cache = PlexImageMemoryCache.shared
-
- for artwork in seededArtwork {
- guard let cgImage = artwork.image.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
- continue
- }
-
- let cacheKey = "\(artwork.url.absoluteString)|\(server.accessToken)"
- cache.insert(artwork.image, for: cacheKey)
- cache.insert(cgImage, for: cacheKey)
-
- if let localURL = Self.localAvatarResourceURL(for: artwork.url.path),
- localURL.absoluteString == authenticatedUser.thumb {
- cache.insert(artwork.image, for: localURL.absoluteString)
- cache.insert(cgImage, for: localURL.absoluteString)
- }
- }
- }
-
- private static func localAvatarResourceURL(for thumb: String?) -> URL? {
- guard let thumb = thumb?.nilIfBlank else {
- return nil
- }
-
- return PlexMockServerResourceLocator.url(for: "avatars/\(URL(fileURLWithPath: thumb).lastPathComponent)")
- }
-
- func response(for request: URLRequest, state: PlexDebugMockState) -> PlexDebugMockResponse? {
- guard let url = request.url else {
- return nil
- }
-
- if isMockServer(url) {
- return serverResponse(for: request, state: state)
- }
-
- if PlexRemoteService.isPlexHosted(url) {
- return remoteResponse(for: request)
- }
-
- return nil
- }
-
- private func isMockServer(_ url: URL) -> Bool {
- let fixtureURL = activeConnection.url
- return url.scheme == fixtureURL.scheme && url.host == fixtureURL.host && url.port == fixtureURL.port
- }
-
- private func serverResponse(for request: URLRequest, state: PlexDebugMockState) -> PlexDebugMockResponse? {
- guard let url = request.url else {
- return nil
- }
-
- if url.path == "/photo/:/transcode" {
- return transcodedImageResponse(for: url)
- }
-
- if url.path.hasPrefix("/mock/avatars/") || url.path.hasPrefix("/mock/art/") {
- return imageResponse(for: url)
- }
-
- if url.path == "/identity" {
- return jsonResponse(
- url: url,
- object: [
- "MediaContainer": [
- "claimed": true,
- "machineIdentifier": server.id,
- "version": server.productVersion ?? ""
- ]
- ]
- )
- }
-
- if url.path == "/status/sessions" {
- let sessionKey = URLComponents(url: url, resolvingAgainstBaseURL: false)?
- .queryItems?
- .first(where: { $0.name == "sessionKey" })?
- .value
- let filteredSessions = sessions.filter { session in
- guard state.isTerminated(session) == false else {
- return false
- }
-
- guard let sessionKey else {
- return true
- }
-
- return session.canonicalSessionKey == sessionKey
- }
- return jsonResponse(
- url: url,
- object: [
- "MediaContainer": [
- "Metadata": filteredSessions.map { sessionObject(from: $0) }
- ]
- ]
- )
- }
-
- if url.path == "/status/sessions/terminate" {
- if let sessionID = URLComponents(url: url, resolvingAgainstBaseURL: false)?
- .queryItems?
- .first(where: { $0.name == "sessionId" })?
- .value {
- state.terminateSession(withID: sessionID)
- }
-
- return jsonResponse(url: url, object: ["MediaContainer": [:]])
- }
-
- if let streamID = streamID(forLevelsPath: url.path),
- let levels = streamLevelsByID[streamID] {
- return jsonResponse(
- url: url,
- object: [
- "MediaContainer": [
- "size": levels.count,
- "totalSamples": String(levels.count),
- "Level": levels.map { ["v": $0] }
- ]
- ]
- )
- }
-
- if url.path == "/status/sessions/history/all" {
- return jsonResponse(
- url: url,
- object: [
- "MediaContainer": [
- "Metadata": historyItems.map { historyItemObject(from: $0) }
- ]
- ],
- headers: ["X-Plex-Container-Total-Size": String(historyItems.count)]
- )
- }
-
- if let metadataIDs = metadataIDs(for: url.path) {
- let metadata = metadataItems
- .filter { metadataIDs.contains($0.ratingKey) }
- .map(metadataItemObject(from:))
- return jsonResponse(
- url: url,
- object: [
- "MediaContainer": [
- "Metadata": metadata
- ]
- ]
- )
- }
-
- if url.path == "/statistics/media" {
- let accounts = accountsByID.keys.sorted().compactMap { accountsByID[$0] }.map { accountObject(from: $0) }
- return jsonResponse(
- url: url,
- object: [
- "MediaContainer": [
- "Account": accounts
- ]
- ]
- )
- }
-
- if url.path == "/library/sections/all" {
- return jsonResponse(
- url: url,
- object: [
- "MediaContainer": [
- "Directory": librarySections.map { libraryDirectoryObject(from: $0) }
- ]
- ]
- )
- }
-
- if let libraryID = libraryID(for: url.path),
- let librarySection = librarySections.first(where: { $0.library.id == libraryID }) {
- let requestedType = URLComponents(url: url, resolvingAgainstBaseURL: false)?
- .queryItems?
- .first(where: { $0.name == "type" })?
- .value
- .flatMap(Int.init)
- let containerStart = Int(request.value(forHTTPHeaderField: "X-Plex-Container-Start") ?? "") ?? 0
- let containerSize = Int(request.value(forHTTPHeaderField: "X-Plex-Container-Size") ?? "") ?? 1
-
- let totalSize = requestedType.flatMap { librarySection.countOverrides[$0] } ?? librarySection.library.itemCount
- let metadata = if requestedType == nil && containerSize != 0 {
- Array(
- librarySection.recentItems
- .dropFirst(containerStart)
- .prefix(containerSize)
- .map(libraryRecentItemObject(from:))
- )
- } else {
- []
- }
-
- return jsonResponse(
- url: url,
- object: [
- "MediaContainer": [
- "size": metadata.count,
- "totalSize": totalSize,
- "Metadata": metadata
- ]
- ],
- headers: ["X-Plex-Container-Total-Size": String(totalSize)]
- )
- }
-
- return nil
- }
-
- private func remoteResponse(for request: URLRequest) -> PlexDebugMockResponse? {
- guard let url = request.url else {
- return nil
- }
-
- if url.path == "/api/v2/user" {
- return jsonResponse(
- url: url,
- object: compactObject([
- "id": authenticatedUser.id,
- "username": authenticatedUser.username,
- "title": authenticatedUser.title,
- "email": authenticatedUser.email,
- "thumb": authenticatedUser.thumb,
- "friendlyName": authenticatedUser.friendlyName,
- ])
- )
- }
-
- if url.path == "/api/resources" {
- return serverResourcesResponse(for: url)
- }
-
- if url.path == "/api/v2/pins" {
- return jsonResponse(
- url: url,
- object: [
- "id": 4242,
- "code": "PLEXBAR-MOCK",
- "authToken": NSNull()
- ]
- )
- }
-
- if url.path.hasPrefix("/api/v2/pins/") {
- return jsonResponse(
- url: url,
- object: [
- "id": 4242,
- "code": "PLEXBAR-MOCK",
- "authToken": userToken
- ]
- )
- }
-
- if url.path == "/api/v2/geoip" {
- return geoIPResponse(for: url)
- }
-
- return nil
- }
-
- private func geoIPResponse(for url: URL) -> PlexDebugMockResponse? {
- guard let ipAddress = URLComponents(url: url, resolvingAgainstBaseURL: false)?
- .queryItems?
- .first(where: { $0.name == "ip_address" })?
- .value else {
- return nil
- }
-
- let location = sessions
- .first(where: { $0.geoLookupIPAddress == ipAddress })
- .flatMap { session in
- session.canonicalSessionKey.flatMap { resolvedLocationsBySessionKey[$0] }
- }
-
- let xml: String
- if let location {
- let parts = location.split(separator: ",", maxSplits: 1).map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
- if parts.count == 2, parts[1].count <= 3 {
- xml = ""
- } else if parts.count == 2 {
- xml = ""
- } else {
- xml = ""
- }
- } else {
- xml = ""
- }
-
- let data = Data(xml.utf8)
- let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/xml"])!
- return PlexDebugMockResponse(response: response, data: data)
- }
-
- private func imageResponse(for url: URL) -> PlexDebugMockResponse? {
- guard let artwork = seededArtwork.first(where: { $0.url.path == url.path }) else {
- return nil
- }
-
- let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "image/png"])!
- return PlexDebugMockResponse(response: response, data: artwork.data)
- }
-
- private func transcodedImageResponse(for url: URL) -> PlexDebugMockResponse? {
- guard let sourcePath = URLComponents(url: url, resolvingAgainstBaseURL: false)?
- .queryItems?
- .first(where: { $0.name == "url" })?
- .value,
- sourcePath.hasPrefix("/mock/") else {
- return nil
- }
-
- guard let sourceURL = PlexURLBuilder.mediaURL(serverURL: activeConnection.url, path: sourcePath) else {
- return nil
- }
-
- return imageResponse(for: sourceURL)
- }
-
- private func serverResourcesResponse(for url: URL) -> PlexDebugMockResponse? {
- let connectionsXML = server.connections.map { connection in
- """
-
- """
- }
- .joined()
-
- let productVersionAttribute = server.productVersion.map {
- " productVersion=\"\(xmlEscaped($0))\""
- } ?? ""
- let xml = """
-
- \(connectionsXML)
-
- """
-
- return xmlResponse(url: url, body: xml)
- }
-
- private func libraryID(for path: String) -> String? {
- let components = path.split(separator: "/")
- guard components.count >= 4,
- components[0] == "library",
- components[1] == "sections",
- components[3] == "all" else {
- return nil
- }
-
- return String(components[2])
- }
-
- private func metadataIDs(for path: String) -> Set? {
- let components = path.split(separator: "/")
- guard components.count >= 3,
- components[0] == "library",
- components[1] == "metadata" else {
- return nil
- }
-
- return Set(components[2].split(separator: ",").map(String.init))
- }
-
- private func streamID(forLevelsPath path: String) -> Int? {
- let components = path.split(separator: "/")
- guard components.count == 4,
- components[0] == "library",
- components[1] == "streams",
- components[3] == "levels" else {
- return nil
- }
-
- return Int(components[2])
- }
-
- private func jsonResponse(url: URL, object: [String: Any], headers: [String: String] = [:]) -> PlexDebugMockResponse? {
- guard let data = try? JSONSerialization.data(withJSONObject: object) else {
- return nil
- }
-
- let response = HTTPURLResponse(
- url: url,
- statusCode: 200,
- httpVersion: nil,
- headerFields: headers.merging(["Content-Type": "application/json"]) { current, _ in current }
- )!
- return PlexDebugMockResponse(response: response, data: data)
- }
-
- private func xmlResponse(url: URL, body: String) -> PlexDebugMockResponse {
- let data = Data(body.utf8)
- let response = HTTPURLResponse(
- url: url,
- statusCode: 200,
- httpVersion: nil,
- headerFields: ["Content-Type": "application/xml"]
- )!
- return PlexDebugMockResponse(response: response, data: data)
- }
-
- private func sessionObject(from session: PlexSession) -> [String: Any] {
- var object = compactObject([
- "sessionKey": session.sessionKey,
- "ratingKey": session.ratingKey,
- "key": session.key,
- "type": session.type,
- "title": session.title,
- "grandparentTitle": session.grandparentTitle,
- "parentTitle": session.parentTitle,
- "parentIndex": session.parentIndex,
- "index": session.index,
- "thumb": session.thumb,
- "parentThumb": session.parentThumb,
- "grandparentThumb": session.grandparentThumb,
- "art": session.art,
- "duration": session.duration,
- "viewOffset": session.viewOffset,
- "year": session.year,
- "User": compactObject([
- "id": session.user?.id,
- "thumb": session.user?.thumb,
- "title": session.user?.title,
- ]),
- "Player": compactObject([
- "address": session.player.address,
- "machineIdentifier": session.player.machineIdentifier,
- "platform": session.player.platform,
- "product": session.player.product,
- "remotePublicAddress": session.player.remotePublicAddress,
- "state": session.player.state,
- "title": session.player.title,
- "local": session.player.local,
- "relayed": session.player.relayed,
- "secure": session.player.secure,
- ]),
- "Session": compactObject([
- "id": session.session?.id,
- "bandwidth": session.session?.bandwidth,
- "location": session.session?.location,
- ]),
- ])
-
- if let transcodeSession = session.transcodeSession {
- object["TranscodeSession"] = compactObject(["key": transcodeSession.key])
- }
-
- if let media = session.media {
- object["Media"] = media.map { media in
- [
- "Part": (media.part ?? []).map { part in
- var partObject = compactObject(["decision": part.decision])
-
- if let stream = part.stream {
- partObject["Stream"] = stream.map { stream in
- compactObject([
- "id": stream.id,
- "streamType": stream.streamType,
- "codec": stream.codec,
- "selected": stream.selected,
- ])
- }
- }
-
- return partObject
- }
- ]
- }
- }
-
- return object
- }
-
- private func historyItemObject(from item: PlexHistoryItem) -> [String: Any] {
- compactObject([
- "historyKey": item.historyKey,
- "key": item.key,
- "ratingKey": item.ratingKey,
- "title": item.title,
- "type": item.type,
- "thumb": item.thumb,
- "parentThumb": item.parentThumb,
- "grandparentThumb": item.grandparentThumb,
- "art": item.art,
- "grandparentTitle": item.grandparentTitle,
- "parentTitle": item.parentTitle,
- "parentIndex": item.parentIndex,
- "index": item.index,
- "originallyAvailableAt": item.originallyAvailableAt,
- "viewedAt": item.viewedAt.map { Int($0.timeIntervalSince1970) },
- "accountID": item.accountID,
- "deviceID": item.deviceID,
- ])
- }
-
- private func accountObject(from account: PlexAccount) -> [String: Any] {
- compactObject([
- "id": account.id,
- "name": account.name,
- "thumb": account.thumb,
- ])
- }
-
- private func metadataItemObject(from item: PlexMetadataItem) -> [String: Any] {
- compactObject([
- "ratingKey": item.ratingKey,
- "grandparentRatingKey": item.grandparentRatingKey,
- "grandparentTitle": item.grandparentTitle,
- "grandparentThumb": item.grandparentThumb,
- ])
- }
-
- private func libraryDirectoryObject(from section: PlexDebugMockLibrarySection) -> [String: Any] {
- let library = section.library
- return compactObject([
- "key": library.id,
- "title": library.title,
- "type": section.rawType,
- "composite": library.compositePath,
- "art": library.artPath,
- "thumb": library.thumbPath,
- "updatedAt": library.updatedAt.map { Int($0.timeIntervalSince1970) },
- "scannedAt": library.scannedAt.map { Int($0.timeIntervalSince1970) },
- "contentChangedAt": library.contentChangedAt.map { Int($0.timeIntervalSince1970) },
- "content": true,
- "directory": true,
- ])
- }
-
- private func libraryRecentItemObject(from item: PlexDebugMockLibraryItem) -> [String: Any] {
- compactObject([
- "ratingKey": item.ratingKey,
- "title": item.title,
- "addedAt": item.addedAt.map { Int($0.timeIntervalSince1970) },
- "art": item.art,
- "thumb": item.thumb,
- ])
- }
-
- private func compactObject(_ values: [String: Any?]) -> [String: Any] {
- values.compactMapValues { $0 }
- }
-
- private func xmlEscaped(_ value: S) -> String {
- String(value)
- .replacingOccurrences(of: "&", with: "&")
- .replacingOccurrences(of: "\"", with: """)
- .replacingOccurrences(of: "<", with: "<")
- .replacingOccurrences(of: ">", with: ">")
- }
-
- private static func materializeSession(
- _ session: PlexMockServerPayload.ActiveSession,
- usersByID: [Int: PlexMockServerPayload.User],
- moviesByID: [String: PlexMockServerPayload.Movie],
- showsByID: [String: PlexMockServerPayload.Show],
- episodesByID: [String: PlexMockServerPayload.Episode],
- audiobooksByID: [String: PlexMockServerPayload.Audiobook]
- ) -> PlexSession {
- let user = resolvedUser(for: session.userID, usersByID: usersByID)
- let media = resolvedMedia(
- type: session.mediaType,
- id: session.mediaID,
- moviesByID: moviesByID,
- showsByID: showsByID,
- episodesByID: episodesByID,
- audiobooksByID: audiobooksByID
- )
- let mediaParts: [PlexMedia]?
- if session.mediaDecision != nil || session.audioStream != nil {
- let stream = session.audioStream.map {
- PlexStream(
- id: $0.id,
- streamType: $0.streamType,
- codec: $0.codec,
- selected: $0.selected
- )
- }
- mediaParts = [PlexMedia(part: [PlexPart(
- decision: session.mediaDecision,
- stream: stream.map { [$0] }
- )])]
- } else {
- mediaParts = nil
- }
-
- return PlexSession(
- sessionKey: session.sessionKey,
- ratingKey: media.id,
- key: "/library/metadata/\(media.id)",
- type: media.type,
- subtype: nil,
- live: false,
- title: media.title,
- grandparentTitle: media.grandparentTitle,
- parentTitle: media.parentTitle,
- parentIndex: media.parentIndex,
- index: media.index,
- thumb: media.thumb,
- parentThumb: media.parentThumb,
- grandparentThumb: media.grandparentThumb,
- art: media.art,
- duration: session.duration,
- viewOffset: session.viewOffset,
- year: media.year,
- user: user,
- player: session.player.materialize(),
- session: session.session?.materialize(),
- transcodeSession: session.transcodeSession?.materialize(),
- media: mediaParts
- )
- }
-
- private static func materializeHistoryItem(
- _ event: PlexMockServerPayload.HistoryEvent,
- referenceDate: Date,
- moviesByID: [String: PlexMockServerPayload.Movie],
- showsByID: [String: PlexMockServerPayload.Show],
- episodesByID: [String: PlexMockServerPayload.Episode]
- ) -> PlexHistoryItem {
- let media = resolvedMedia(
- type: event.mediaType,
- id: event.mediaID,
- moviesByID: moviesByID,
- showsByID: showsByID,
- episodesByID: episodesByID,
- audiobooksByID: [:]
- )
-
- return PlexHistoryItem(
- historyKey: event.historyKey,
- key: "/library/metadata/\(media.id)",
- ratingKey: media.id,
- title: media.title,
- type: media.type,
- thumb: media.thumb,
- parentThumb: media.parentThumb,
- grandparentThumb: media.grandparentThumb,
- art: media.art,
- grandparentTitle: media.grandparentTitle,
- parentTitle: media.parentTitle,
- parentIndex: media.parentIndex,
- index: media.index,
- originallyAvailableAt: media.originallyAvailableAt,
- viewedAt: referenceDate.addingTimeInterval(-TimeInterval(event.viewedAtSecondsAgo)),
- accountID: event.userID,
- deviceID: event.deviceID
- )
- }
-
- private static func materializeMetadataItem(
- _ episode: PlexMockServerPayload.Episode,
- showsByID: [String: PlexMockServerPayload.Show]
- ) -> PlexMetadataItem {
- guard let show = showsByID[episode.showID] else {
- preconditionFailure("Missing mock show \(episode.showID) for episode \(episode.id)")
- }
-
- return PlexMetadataItem(
- ratingKey: episode.id,
- grandparentRatingKey: show.id,
- grandparentTitle: show.title,
- grandparentThumb: show.poster
- )
- }
-
- private static func materializeLibrarySection(
- _ library: PlexMockServerPayload.Library,
- referenceDate: Date,
- moviesByID: [String: PlexMockServerPayload.Movie],
- showsByID: [String: PlexMockServerPayload.Show],
- audiobooksByID: [String: PlexMockServerPayload.Audiobook]
- ) -> PlexDebugMockLibrarySection {
- let recentItems: [PlexDebugMockLibraryItem]
- if library.type == "artist" {
- var latestEntryByArtist: [String: (entry: PlexMockServerPayload.LibraryEntry, audiobook: PlexMockServerPayload.Audiobook)] = [:]
- var artistOrder: [String] = []
-
- for entry in library.entries.sorted(by: { $0.addedAtSecondsAgo < $1.addedAtSecondsAgo }) {
- guard let audiobook = audiobooksByID[entry.mediaID],
- let artistTitle = audiobook.artistTitle?.nilIfBlank else {
- continue
- }
-
- if latestEntryByArtist[artistTitle] == nil {
- artistOrder.append(artistTitle)
- latestEntryByArtist[artistTitle] = (entry, audiobook)
- }
- }
-
- recentItems = artistOrder.compactMap { artistTitle -> PlexDebugMockLibraryItem? in
- guard let resolved = latestEntryByArtist[artistTitle] else {
- return nil
- }
-
- return PlexDebugMockLibraryItem(
- ratingKey: resolved.audiobook.id,
- title: artistTitle,
- addedAt: referenceDate.addingTimeInterval(-TimeInterval(resolved.entry.addedAtSecondsAgo)),
- art: resolved.audiobook.art ?? resolved.audiobook.cover,
- thumb: resolved.audiobook.cover
- )
- }
- } else {
- recentItems = library.entries
- .sorted { $0.addedAtSecondsAgo < $1.addedAtSecondsAgo }
- .map {
- resolvedLibraryItem(
- type: library.type,
- id: $0.mediaID,
- moviesByID: moviesByID,
- showsByID: showsByID,
- audiobooksByID: audiobooksByID
- ).recentItem(addedAt: referenceDate.addingTimeInterval(-TimeInterval($0.addedAtSecondsAgo)))
- }
- }
-
- let latestItem = recentItems.first
- let secondarySummary = library.secondarySummary
-
- return PlexDebugMockLibrarySection(
- library: PlexLibrary(
- id: library.id,
- title: library.title,
- type: PlexLibraryType(rawValue: library.type),
- compositePath: latestItem?.thumb,
- artPath: latestItem?.art,
- thumbPath: latestItem?.thumb,
- itemCount: recentItems.count,
- secondaryCount: secondarySummary?.count,
- secondaryCountLabel: secondarySummary?.label,
- updatedAt: library.updatedAtSecondsAgo.map { referenceDate.addingTimeInterval(-TimeInterval($0)) },
- scannedAt: library.scannedAtSecondsAgo.map { referenceDate.addingTimeInterval(-TimeInterval($0)) },
- contentChangedAt: library.contentChangedAtSecondsAgo.map { referenceDate.addingTimeInterval(-TimeInterval($0)) },
- latestAddedAt: latestItem?.addedAt,
- latestItemTitle: latestItem?.title
- ),
- rawType: library.type,
- recentItems: recentItems,
- countOverrides: secondarySummary.map { [$0.queryType: $0.count] } ?? [:]
- )
- }
-
- private static func resolvedUser(
- for userID: Int,
- usersByID: [Int: PlexMockServerPayload.User]
- ) -> PlexUser {
- guard let user = usersByID[userID] else {
- preconditionFailure("Missing mock user \(userID)")
- }
-
- return user.materializeUser()
- }
-
- private static func resolvedMedia(
- type: String,
- id: String,
- moviesByID: [String: PlexMockServerPayload.Movie],
- showsByID: [String: PlexMockServerPayload.Show],
- episodesByID: [String: PlexMockServerPayload.Episode],
- audiobooksByID: [String: PlexMockServerPayload.Audiobook]
- ) -> PlexDebugResolvedMedia {
- switch type {
- case "movie":
- guard let movie = moviesByID[id] else {
- preconditionFailure("Missing mock movie \(id)")
- }
-
- return PlexDebugResolvedMedia(
- id: movie.id,
- type: "movie",
- title: movie.title,
- year: movie.year,
- thumb: movie.poster,
- parentThumb: nil,
- grandparentThumb: nil,
- art: movie.art,
- grandparentTitle: nil,
- parentTitle: nil,
- parentIndex: nil,
- index: nil,
- originallyAvailableAt: movie.originallyAvailableAt
- )
- case "episode":
- guard let episode = episodesByID[id] else {
- preconditionFailure("Missing mock episode \(id)")
- }
- guard let show = showsByID[episode.showID] else {
- preconditionFailure("Missing mock show \(episode.showID) for episode \(id)")
- }
-
- return PlexDebugResolvedMedia(
- id: episode.id,
- type: "episode",
- title: episode.title,
- year: nil,
- thumb: nil,
- parentThumb: nil,
- grandparentThumb: show.poster,
- art: show.art,
- grandparentTitle: show.title,
- parentTitle: "Season \(episode.seasonNumber)",
- parentIndex: episode.seasonNumber,
- index: episode.episodeNumber,
- originallyAvailableAt: episode.originallyAvailableAt
- )
- case "audiobook":
- guard let audiobook = audiobooksByID[id] else {
- preconditionFailure("Missing mock audiobook \(id)")
- }
-
- return PlexDebugResolvedMedia(
- id: audiobook.id,
- type: "track",
- title: audiobook.trackTitle ?? audiobook.title,
- year: audiobook.year,
- thumb: audiobook.cover,
- parentThumb: audiobook.cover,
- grandparentThumb: nil,
- art: audiobook.art,
- grandparentTitle: audiobook.artistTitle,
- parentTitle: audiobook.albumTitle ?? audiobook.title,
- parentIndex: nil,
- index: nil,
- originallyAvailableAt: nil
- )
- default:
- preconditionFailure("Unsupported mock media type \(type)")
- }
- }
-
- private static func resolvedLibraryItem(
- type: String,
- id: String,
- moviesByID: [String: PlexMockServerPayload.Movie],
- showsByID: [String: PlexMockServerPayload.Show],
- audiobooksByID: [String: PlexMockServerPayload.Audiobook]
- ) -> PlexDebugResolvedLibraryItem {
- switch type {
- case "movie":
- guard let movie = moviesByID[id] else {
- preconditionFailure("Missing mock movie \(id)")
- }
-
- return PlexDebugResolvedLibraryItem(
- ratingKey: movie.id,
- title: movie.title,
- thumb: movie.poster,
- art: movie.art
- )
- case "show":
- guard let show = showsByID[id] else {
- preconditionFailure("Missing mock show \(id)")
- }
-
- return PlexDebugResolvedLibraryItem(
- ratingKey: show.id,
- title: show.title,
- thumb: show.poster,
- art: show.art
- )
- case "audiobook", "artist":
- guard let audiobook = audiobooksByID[id] else {
- preconditionFailure("Missing mock audiobook \(id)")
- }
-
- return PlexDebugResolvedLibraryItem(
- ratingKey: audiobook.id,
- title: audiobook.title,
- thumb: audiobook.cover,
- art: audiobook.art ?? audiobook.cover
- )
- default:
- preconditionFailure("Unsupported mock library type \(type)")
- }
- }
-}
-
-private struct DebugSeededArtwork {
- let url: URL
- let data: Data
- let image: NSImage
-
- static func load(
- serverURL: URL,
- mockPath: String,
- sourceFileName: String,
- resourceDirectory: String = "Resources/MockServer/avatars"
- ) -> DebugSeededArtwork {
- let mockServerPrefix = "Resources/MockServer/"
- let relativeDirectory = resourceDirectory.replacingOccurrences(of: mockServerPrefix, with: "")
- let sourceURL = PlexMockServerResourceLocator.url(for: "\(relativeDirectory)/\(sourceFileName)")
-
- let data = try! Data(contentsOf: sourceURL)
- guard let image = NSImage(contentsOf: sourceURL) else {
- preconditionFailure("Missing mock avatar image at \(sourceURL.path)")
- }
-
- return DebugSeededArtwork(
- url: PlexURLBuilder.mediaURL(serverURL: serverURL, path: mockPath)!,
- data: data,
- image: image
- )
- }
-}
-
-private struct PlexDebugMockLibrarySection {
- let library: PlexLibrary
- let rawType: String
- let recentItems: [PlexDebugMockLibraryItem]
- let countOverrides: [Int: Int]
-}
-
-private struct PlexDebugMockLibraryItem {
- let ratingKey: String
- let title: String
- let addedAt: Date?
- let art: String?
- let thumb: String?
-}
-
-private struct PlexDebugResolvedMedia {
- let id: String
- let type: String
- let title: String
- let year: Int?
- let thumb: String?
- let parentThumb: String?
- let grandparentThumb: String?
- let art: String?
- let grandparentTitle: String?
- let parentTitle: String?
- let parentIndex: Int?
- let index: Int?
- let originallyAvailableAt: String?
-}
-
-private struct PlexDebugResolvedLibraryItem {
- let ratingKey: String
- let title: String
- let thumb: String?
- let art: String?
-
- func recentItem(addedAt: Date) -> PlexDebugMockLibraryItem {
- PlexDebugMockLibraryItem(
- ratingKey: ratingKey,
- title: title,
- addedAt: addedAt,
- art: art,
- thumb: thumb
- )
- }
-}
-
-private final class PlexDebugMockState: @unchecked Sendable {
- private let lock = NSLock()
- private var terminatedSessionIDs: Set