diff --git a/Package.swift b/Package.swift
index 11ec180..72bc2f4 100644
--- a/Package.swift
+++ b/Package.swift
@@ -23,8 +23,11 @@ let package = Package(
dependencies: [
.product(name: "Sparkle", package: "Sparkle")
],
+ exclude: [
+ "Resources/MockServer"
+ ],
resources: [
- .process("Resources")
+ .process("Resources/MenuBarIcon")
]
),
.testTarget(
diff --git a/README.md b/README.md
index 992a720..6bfdfe1 100644
--- a/README.md
+++ b/README.md
@@ -6,8 +6,10 @@
PlexBar is a lightweight macOS menu bar app for Plex server telemetry.
-
-
+
+
+
+
## Features
@@ -30,6 +32,12 @@ To build and run the app:
script/build_and_run.sh
```
+To run with mock data:
+
+```bash
+script/build_and_run.sh --mock
+```
+
To package the app as a `dmg`:
```bash
diff --git a/Sources/PlexBar/App/PlexBarApp.swift b/Sources/PlexBar/App/PlexBarApp.swift
index 1f288e4..1d34152 100644
--- a/Sources/PlexBar/App/PlexBarApp.swift
+++ b/Sources/PlexBar/App/PlexBarApp.swift
@@ -21,13 +21,23 @@ struct PlexBarApp: App {
private let updateService: PlexUpdateService
init() {
- let settingsStore = PlexSettingsStore()
- let resolver = PlexConnectionResolver()
+ let runtime = PlexAppRuntime.current()
+ let settingsStore = runtime.settingsStore
+ let resolver = runtime.connectionResolver
let connectionStore = PlexConnectionStore(settings: settingsStore, resolver: resolver)
- let sessionStore = PlexSessionStore(connectionStore: connectionStore)
- let libraryStore = PlexLibraryStore(connectionStore: connectionStore)
- let historyStore = PlexHistoryStore(connectionStore: connectionStore, libraryStore: libraryStore)
- let serverPreviewStore = PlexServerPreviewStore(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)
@@ -39,7 +49,8 @@ struct PlexBarApp: App {
connectionStore: connectionStore,
sessionStore: sessionStore,
historyStore: historyStore,
- libraryStore: libraryStore
+ libraryStore: libraryStore,
+ client: runtime.authClient
))
updateService = PlexUpdateService()
}
diff --git a/Sources/PlexBar/Models/PlexServerPreview.swift b/Sources/PlexBar/Models/PlexServerPreview.swift
index f11db56..9d6c912 100644
--- a/Sources/PlexBar/Models/PlexServerPreview.swift
+++ b/Sources/PlexBar/Models/PlexServerPreview.swift
@@ -14,8 +14,12 @@ struct PlexServerPreviewItem: Identifiable, Equatable {
let artworkPath: String?
let addedAt: Date
+ var displayArtworkPath: String? {
+ posterPath ?? artworkPath
+ }
+
var hasArtwork: Bool {
- posterPath != nil || artworkPath != nil
+ displayArtworkPath != nil
}
}
diff --git a/Sources/PlexBar/Resources/MenuBarIcon.png b/Sources/PlexBar/Resources/MenuBarIcon/MenuBarIcon.png
similarity index 100%
rename from Sources/PlexBar/Resources/MenuBarIcon.png
rename to Sources/PlexBar/Resources/MenuBarIcon/MenuBarIcon.png
diff --git a/Sources/PlexBar/Resources/MenuBarIcon@2x.png b/Sources/PlexBar/Resources/MenuBarIcon/MenuBarIcon@2x.png
similarity index 100%
rename from Sources/PlexBar/Resources/MenuBarIcon@2x.png
rename to Sources/PlexBar/Resources/MenuBarIcon/MenuBarIcon@2x.png
diff --git a/Sources/PlexBar/Resources/MockServer/art/audiobooks/dracula.png b/Sources/PlexBar/Resources/MockServer/art/audiobooks/dracula.png
new file mode 100644
index 0000000..cb4e9f5
Binary files /dev/null and b/Sources/PlexBar/Resources/MockServer/art/audiobooks/dracula.png differ
diff --git a/Sources/PlexBar/Resources/MockServer/art/audiobooks/originals/dracula.png b/Sources/PlexBar/Resources/MockServer/art/audiobooks/originals/dracula.png
new file mode 100644
index 0000000..f379f2d
Binary files /dev/null and b/Sources/PlexBar/Resources/MockServer/art/audiobooks/originals/dracula.png differ
diff --git a/Sources/PlexBar/Resources/MockServer/art/audiobooks/originals/the-time-machine.png b/Sources/PlexBar/Resources/MockServer/art/audiobooks/originals/the-time-machine.png
new file mode 100644
index 0000000..e28ac29
Binary files /dev/null and b/Sources/PlexBar/Resources/MockServer/art/audiobooks/originals/the-time-machine.png differ
diff --git a/Sources/PlexBar/Resources/MockServer/art/audiobooks/originals/war-of-the-worlds.png b/Sources/PlexBar/Resources/MockServer/art/audiobooks/originals/war-of-the-worlds.png
new file mode 100644
index 0000000..bc25a7c
Binary files /dev/null and b/Sources/PlexBar/Resources/MockServer/art/audiobooks/originals/war-of-the-worlds.png differ
diff --git a/Sources/PlexBar/Resources/MockServer/art/audiobooks/the-time-machine.png b/Sources/PlexBar/Resources/MockServer/art/audiobooks/the-time-machine.png
new file mode 100644
index 0000000..f38e58c
Binary files /dev/null and b/Sources/PlexBar/Resources/MockServer/art/audiobooks/the-time-machine.png differ
diff --git a/Sources/PlexBar/Resources/MockServer/art/audiobooks/war-of-the-worlds.png b/Sources/PlexBar/Resources/MockServer/art/audiobooks/war-of-the-worlds.png
new file mode 100644
index 0000000..06a6e15
Binary files /dev/null and b/Sources/PlexBar/Resources/MockServer/art/audiobooks/war-of-the-worlds.png differ
diff --git a/Sources/PlexBar/Resources/MockServer/art/movies/charade.png b/Sources/PlexBar/Resources/MockServer/art/movies/charade.png
new file mode 100644
index 0000000..78eeee3
Binary files /dev/null and b/Sources/PlexBar/Resources/MockServer/art/movies/charade.png differ
diff --git a/Sources/PlexBar/Resources/MockServer/art/movies/night-of-the-living-dead.png b/Sources/PlexBar/Resources/MockServer/art/movies/night-of-the-living-dead.png
new file mode 100644
index 0000000..2e2af1b
Binary files /dev/null and b/Sources/PlexBar/Resources/MockServer/art/movies/night-of-the-living-dead.png differ
diff --git a/Sources/PlexBar/Resources/MockServer/art/movies/originals/charade.png b/Sources/PlexBar/Resources/MockServer/art/movies/originals/charade.png
new file mode 100644
index 0000000..c68b6bf
Binary files /dev/null and b/Sources/PlexBar/Resources/MockServer/art/movies/originals/charade.png differ
diff --git a/Sources/PlexBar/Resources/MockServer/art/movies/originals/night-of-the-living-dead.png b/Sources/PlexBar/Resources/MockServer/art/movies/originals/night-of-the-living-dead.png
new file mode 100644
index 0000000..e5b3513
Binary files /dev/null and b/Sources/PlexBar/Resources/MockServer/art/movies/originals/night-of-the-living-dead.png differ
diff --git a/Sources/PlexBar/Resources/MockServer/art/movies/originals/sherlock-jr.png b/Sources/PlexBar/Resources/MockServer/art/movies/originals/sherlock-jr.png
new file mode 100644
index 0000000..850ca33
Binary files /dev/null and b/Sources/PlexBar/Resources/MockServer/art/movies/originals/sherlock-jr.png differ
diff --git a/Sources/PlexBar/Resources/MockServer/art/movies/sherlock-jr.png b/Sources/PlexBar/Resources/MockServer/art/movies/sherlock-jr.png
new file mode 100644
index 0000000..420edef
Binary files /dev/null and b/Sources/PlexBar/Resources/MockServer/art/movies/sherlock-jr.png differ
diff --git a/Sources/PlexBar/Resources/MockServer/art/tv/abbott-and-costello.png b/Sources/PlexBar/Resources/MockServer/art/tv/abbott-and-costello.png
new file mode 100644
index 0000000..a813381
Binary files /dev/null and b/Sources/PlexBar/Resources/MockServer/art/tv/abbott-and-costello.png 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
new file mode 100644
index 0000000..ca91e04
Binary files /dev/null and b/Sources/PlexBar/Resources/MockServer/art/tv/adventures-of-ozzie-and-harriet.png 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
new file mode 100644
index 0000000..9e319d8
Binary files /dev/null and b/Sources/PlexBar/Resources/MockServer/art/tv/one-step-beyond.png differ
diff --git a/Sources/PlexBar/Resources/MockServer/art/tv/originals/abbott-and-costello.png b/Sources/PlexBar/Resources/MockServer/art/tv/originals/abbott-and-costello.png
new file mode 100644
index 0000000..4b88dc6
Binary files /dev/null and b/Sources/PlexBar/Resources/MockServer/art/tv/originals/abbott-and-costello.png 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
new file mode 100644
index 0000000..83cfc52
Binary files /dev/null and b/Sources/PlexBar/Resources/MockServer/art/tv/originals/adventures-of-ozzie-and-harriet.png differ
diff --git a/Sources/PlexBar/Resources/MockServer/art/tv/originals/one-step-beyond.png b/Sources/PlexBar/Resources/MockServer/art/tv/originals/one-step-beyond.png
new file mode 100644
index 0000000..90c3470
Binary files /dev/null and b/Sources/PlexBar/Resources/MockServer/art/tv/originals/one-step-beyond.png differ
diff --git a/Sources/PlexBar/Resources/MockServer/avatars/dana-scully.png b/Sources/PlexBar/Resources/MockServer/avatars/dana-scully.png
new file mode 100644
index 0000000..a6e3c35
Binary files /dev/null and b/Sources/PlexBar/Resources/MockServer/avatars/dana-scully.png differ
diff --git a/Sources/PlexBar/Resources/MockServer/avatars/darlene-alderson.png b/Sources/PlexBar/Resources/MockServer/avatars/darlene-alderson.png
new file mode 100644
index 0000000..b364634
Binary files /dev/null and b/Sources/PlexBar/Resources/MockServer/avatars/darlene-alderson.png differ
diff --git a/Sources/PlexBar/Resources/MockServer/avatars/elliot-alderson.png b/Sources/PlexBar/Resources/MockServer/avatars/elliot-alderson.png
new file mode 100644
index 0000000..db817cb
Binary files /dev/null and b/Sources/PlexBar/Resources/MockServer/avatars/elliot-alderson.png differ
diff --git a/Sources/PlexBar/Resources/MockServer/avatars/le-petit-prince.png b/Sources/PlexBar/Resources/MockServer/avatars/le-petit-prince.png
new file mode 100644
index 0000000..d7154c5
Binary files /dev/null and b/Sources/PlexBar/Resources/MockServer/avatars/le-petit-prince.png differ
diff --git a/Sources/PlexBar/Resources/MockServer/avatars/originals/dana-scully.png b/Sources/PlexBar/Resources/MockServer/avatars/originals/dana-scully.png
new file mode 100644
index 0000000..f189829
Binary files /dev/null and b/Sources/PlexBar/Resources/MockServer/avatars/originals/dana-scully.png differ
diff --git a/Sources/PlexBar/Resources/MockServer/avatars/originals/darlene-alderson.png b/Sources/PlexBar/Resources/MockServer/avatars/originals/darlene-alderson.png
new file mode 100644
index 0000000..1d88059
Binary files /dev/null and b/Sources/PlexBar/Resources/MockServer/avatars/originals/darlene-alderson.png differ
diff --git a/Sources/PlexBar/Resources/MockServer/avatars/originals/elliot-alderson.png b/Sources/PlexBar/Resources/MockServer/avatars/originals/elliot-alderson.png
new file mode 100644
index 0000000..4a73f34
Binary files /dev/null and b/Sources/PlexBar/Resources/MockServer/avatars/originals/elliot-alderson.png differ
diff --git a/Sources/PlexBar/Resources/MockServer/avatars/originals/le-petit-prince.png b/Sources/PlexBar/Resources/MockServer/avatars/originals/le-petit-prince.png
new file mode 100644
index 0000000..4d5e0bc
Binary files /dev/null and b/Sources/PlexBar/Resources/MockServer/avatars/originals/le-petit-prince.png differ
diff --git a/Sources/PlexBar/Resources/MockServer/avatars/originals/popeye.png b/Sources/PlexBar/Resources/MockServer/avatars/originals/popeye.png
new file mode 100644
index 0000000..82eb0c8
Binary files /dev/null and b/Sources/PlexBar/Resources/MockServer/avatars/originals/popeye.png differ
diff --git a/Sources/PlexBar/Resources/MockServer/avatars/originals/tommy-shelby.png b/Sources/PlexBar/Resources/MockServer/avatars/originals/tommy-shelby.png
new file mode 100644
index 0000000..e4743e2
Binary files /dev/null and b/Sources/PlexBar/Resources/MockServer/avatars/originals/tommy-shelby.png differ
diff --git a/Sources/PlexBar/Resources/MockServer/avatars/popeye.png b/Sources/PlexBar/Resources/MockServer/avatars/popeye.png
new file mode 100644
index 0000000..40618ce
Binary files /dev/null and b/Sources/PlexBar/Resources/MockServer/avatars/popeye.png differ
diff --git a/Sources/PlexBar/Resources/MockServer/avatars/tommy-shelby.png b/Sources/PlexBar/Resources/MockServer/avatars/tommy-shelby.png
new file mode 100644
index 0000000..54ec974
Binary files /dev/null and b/Sources/PlexBar/Resources/MockServer/avatars/tommy-shelby.png differ
diff --git a/Sources/PlexBar/Resources/MockServer/mock-server.json b/Sources/PlexBar/Resources/MockServer/mock-server.json
new file mode 100644
index 0000000..c449840
--- /dev/null
+++ b/Sources/PlexBar/Resources/MockServer/mock-server.json
@@ -0,0 +1,437 @@
+{
+ "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"
+ }
+ ],
+ "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": "Living Room 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"
+ }
+ ],
+ "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
+ }
+ ],
+ "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/Support/PlexAppRuntime.swift b/Sources/PlexBar/Support/PlexAppRuntime.swift
new file mode 100644
index 0000000..47b9518
--- /dev/null
+++ b/Sources/PlexBar/Support/PlexAppRuntime.swift
@@ -0,0 +1,91 @@
+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
new file mode 100644
index 0000000..b6bb93f
--- /dev/null
+++ b/Sources/PlexBar/Support/PlexDebugMockServer.swift
@@ -0,0 +1,1195 @@
+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 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,
+ 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/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 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 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
+ compactObject(["decision": part.decision])
+ }
+ ]
+ }
+ }
+
+ 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 let mediaDecision = session.mediaDecision {
+ [PlexMedia(part: [PlexPart(decision: mediaDecision)])]
+ } else {
+ 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 = []
+
+ 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 {
+ private static let forwardingSession: URLSession = {
+ let configuration = URLSessionConfiguration.ephemeral
+ return URLSession(configuration: configuration)
+ }()
+
+ private var forwardingTask: URLSessionDataTask?
+
+ override class func canInit(with request: URLRequest) -> Bool {
+ true
+ }
+
+ override class func canonicalRequest(for request: URLRequest) -> URLRequest {
+ request
+ }
+
+ override func startLoading() {
+ if let state = PlexDebugMockStateRegistry.shared.state(for: request),
+ let response = debugFixture.response(for: request, state: state) {
+ client?.urlProtocol(self, didReceive: response.response, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: response.data)
+ client?.urlProtocolDidFinishLoading(self)
+ return
+ }
+
+ forwardingTask = Self.forwardingSession.dataTask(with: request) { [weak self] data, response, error in
+ guard let self else {
+ return
+ }
+
+ if let error {
+ client?.urlProtocol(self, didFailWithError: error)
+ return
+ }
+
+ if let response {
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ }
+
+ if let data {
+ client?.urlProtocol(self, didLoad: data)
+ }
+
+ client?.urlProtocolDidFinishLoading(self)
+ }
+ forwardingTask?.resume()
+ }
+
+ override func stopLoading() {
+ forwardingTask?.cancel()
+ forwardingTask = nil
+ }
+}
+
+private struct PlexDebugMockResponse {
+ let response: HTTPURLResponse
+ let data: Data
+}
+
+#endif
diff --git a/Sources/PlexBar/Support/PlexMockServerPayload.swift b/Sources/PlexBar/Support/PlexMockServerPayload.swift
new file mode 100644
index 0000000..b3b68c2
--- /dev/null
+++ b/Sources/PlexBar/Support/PlexMockServerPayload.swift
@@ -0,0 +1,230 @@
+import Foundation
+
+#if DEBUG
+enum PlexMockServerPayloadError: Error {
+ case missingResource
+}
+
+enum PlexMockServerResourceLocator {
+ static func url(for relativePath: String, filePath: String = #filePath) -> URL {
+ URL(fileURLWithPath: filePath)
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ .appending(path: "Resources/MockServer/\(relativePath)")
+ }
+}
+
+struct PlexMockServerPayload: Decodable {
+ let authenticatedUser: AuthenticatedUser
+ let server: Server
+ let users: [User]
+ let movies: [Movie]
+ let shows: [Show]
+ let episodes: [Episode]
+ let audiobooks: [Audiobook]
+ let activeSessions: [ActiveSession]
+ let resolvedLocationsBySessionKey: [String: String]
+ let historyEvents: [HistoryEvent]
+ let libraries: [Library]
+
+ 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)
+ return try JSONDecoder().decode(PlexMockServerPayload.self, from: data)
+ }
+}
+
+extension PlexMockServerPayload {
+ struct AuthenticatedUser: Decodable {
+ let id: Int
+ let username: String
+ let title: String?
+ let email: String?
+ let thumb: String?
+ let friendlyName: String?
+
+ func materialize(thumbOverride: String? = nil) -> PlexAuthenticatedUser {
+ PlexAuthenticatedUser(
+ id: id,
+ username: username,
+ title: title?.nilIfBlank,
+ email: email?.nilIfBlank,
+ thumb: thumbOverride ?? thumb?.nilIfBlank,
+ friendlyName: friendlyName?.nilIfBlank
+ )
+ }
+ }
+
+ struct Server: Decodable {
+ let id: String
+ let name: String
+ let productVersion: String?
+ let accessToken: String
+ let connections: [Connection]
+
+ func materialize() -> PlexServerResource {
+ PlexServerResource(
+ id: id,
+ name: name,
+ productVersion: productVersion,
+ accessToken: accessToken,
+ connections: connections.map { $0.materialize() }
+ )
+ }
+ }
+
+ struct Connection: Decodable {
+ let uri: URL
+ let local: Bool
+ let relay: Bool
+
+ func materialize() -> PlexServerConnection {
+ PlexServerConnection(uri: uri, local: local, relay: relay)
+ }
+ }
+
+ struct User: Decodable {
+ let id: Int
+ let name: String
+ let avatar: String?
+
+ func materialize() -> PlexAccount {
+ PlexAccount(id: id, name: name, thumb: avatar)
+ }
+
+ func materializeUser() -> PlexUser {
+ PlexUser(id: String(id), thumb: avatar, title: name)
+ }
+ }
+
+ struct Movie: Decodable {
+ let id: String
+ let title: String
+ let year: Int?
+ let poster: String?
+ let art: String?
+ let originallyAvailableAt: String?
+ }
+
+ struct Show: Decodable {
+ let id: String
+ let title: String
+ let poster: String?
+ let art: String?
+ }
+
+ struct Episode: Decodable {
+ let id: String
+ let showID: String
+ let title: String
+ let seasonNumber: Int
+ let episodeNumber: Int
+ let originallyAvailableAt: String?
+ }
+
+ struct Audiobook: Decodable {
+ let id: String
+ let title: String
+ let year: Int?
+ let cover: String?
+ let art: String?
+ let artistTitle: String?
+ let albumTitle: String?
+ let trackTitle: String?
+ }
+
+ struct ActiveSession: Decodable {
+ let sessionKey: String
+ let userID: Int
+ let mediaType: String
+ let mediaID: String
+ let duration: Int?
+ let viewOffset: Int?
+ let player: Player
+ let session: PlaybackSession?
+ let transcodeSession: TranscodeSession?
+ let mediaDecision: String?
+ }
+
+ struct HistoryEvent: Decodable {
+ let historyKey: String
+ let userID: Int
+ let mediaType: String
+ let mediaID: String
+ let viewedAtSecondsAgo: Int
+ let deviceID: Int?
+ }
+
+ struct Library: Decodable {
+ let id: String
+ let title: String
+ let type: String
+ let updatedAtSecondsAgo: Int?
+ let scannedAtSecondsAgo: Int?
+ let contentChangedAtSecondsAgo: Int?
+ let entries: [LibraryEntry]
+ let secondarySummary: SecondarySummary?
+ }
+
+ struct LibraryEntry: Decodable {
+ let mediaID: String
+ let addedAtSecondsAgo: Int
+ }
+
+ struct SecondarySummary: Decodable {
+ let queryType: Int
+ let count: Int
+ let label: String
+ }
+
+ struct Player: 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?
+
+ func materialize() -> PlexPlayer {
+ PlexPlayer(
+ address: address,
+ machineIdentifier: machineIdentifier,
+ platform: platform,
+ product: product,
+ remotePublicAddress: remotePublicAddress,
+ state: state,
+ title: title,
+ local: local,
+ relayed: relayed,
+ secure: secure
+ )
+ }
+ }
+
+ struct PlaybackSession: Decodable {
+ let id: String?
+ let bandwidth: Int?
+ let location: String?
+
+ func materialize() -> PlexPlaybackSession {
+ PlexPlaybackSession(id: id, bandwidth: bandwidth, location: location)
+ }
+ }
+
+ struct TranscodeSession: Decodable {
+ let key: String?
+
+ func materialize() -> PlexTranscodeSession {
+ PlexTranscodeSession(key: key)
+ }
+ }
+}
+#endif
diff --git a/Sources/PlexBar/Views/PosterStackView.swift b/Sources/PlexBar/Views/PosterStackView.swift
index f2f7db9..42b9827 100644
--- a/Sources/PlexBar/Views/PosterStackView.swift
+++ b/Sources/PlexBar/Views/PosterStackView.swift
@@ -46,8 +46,8 @@ struct PosterStackView: View {
@ViewBuilder
private func poster(for item: PlexServerPreviewItem) -> some View {
PlexArtworkView(
- primaryImageURL: posterURL(for: item.posterPath),
- fallbackImageURL: posterURL(for: item.artworkPath),
+ primaryImageURL: artworkURL(for: item.posterPath),
+ fallbackImageURL: artworkURL(for: item.artworkPath),
token: token,
clientContext: clientContext,
placeholderSymbol: placeholderSymbol,
@@ -116,16 +116,12 @@ struct PosterStackView: View {
)
}
- private func posterURL(for path: String?) -> URL? {
+ private func artworkURL(for path: String?) -> URL? {
guard let serverURL else {
return nil
}
- return PlexURLBuilder.transcodedArtworkURL(
- serverURL: serverURL,
- path: path,
- width: Int(posterWidth * 2),
- height: Int(posterHeight * 2)
- )
+ return PlexURLBuilder.mediaURL(serverURL: serverURL, path: path)
}
+
}
diff --git a/Tests/PlexBarTests/PlexAppRuntimeTests.swift b/Tests/PlexBarTests/PlexAppRuntimeTests.swift
new file mode 100644
index 0000000..caad57b
--- /dev/null
+++ b/Tests/PlexBarTests/PlexAppRuntimeTests.swift
@@ -0,0 +1,14 @@
+import Testing
+@testable import PlexBar
+
+@MainActor
+@Test func defaultsToLiveRuntimeMode() {
+ #expect(PlexAppRuntime.mode(arguments: ["PlexBar"]) == .live)
+}
+
+#if DEBUG
+@MainActor
+@Test func selectsMockRuntimeModeFromArgument() {
+ #expect(PlexAppRuntime.mode(arguments: ["PlexBar", "--mock"]) == .mock)
+}
+#endif
diff --git a/Tests/PlexBarTests/PlexDebugMockServerTests.swift b/Tests/PlexBarTests/PlexDebugMockServerTests.swift
new file mode 100644
index 0000000..18694a5
--- /dev/null
+++ b/Tests/PlexBarTests/PlexDebugMockServerTests.swift
@@ -0,0 +1,172 @@
+import Foundation
+import Testing
+@testable import PlexBar
+
+#if DEBUG
+@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
+ )
+
+ #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")
+}
+
+@Test func mockAuthenticatedUserAvatarUsesLocalMockResourceURL() 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()
+
+ #expect(thumbURL.isFileURL)
+ #expect(thumbURL.lastPathComponent == "darlene-alderson.png")
+ #expect(await imageClient.fetchImage(
+ from: [thumbURL],
+ token: "",
+ clientContext: PlexClientContext(clientIdentifier: "tests")
+ ) != nil)
+}
+
+@Test func loadsMockServerPayloadFromBundle() throws {
+ let payload = try PlexMockServerPayload.loadDefault()
+ let hasTommyAudiobookSession = payload.activeSessions.contains { session in
+ session.userID == 15 && session.mediaType == "audiobook" && session.mediaID == "3103"
+ }
+
+ #expect(payload.server.name == "Mock Server")
+ #expect(payload.activeSessions.count == 4)
+ #expect(payload.libraries.map(\.title) == ["Movies", "TV Shows", "Audiobooks"])
+ #expect(payload.users.map(\.name) == ["scully", "Elliot", "petit_prince", "popeye23", "TommyS", "D0loresH4ze"])
+ #expect(payload.historyEvents.contains(where: { $0.mediaType == "episode" }))
+ #expect(hasTommyAudiobookSession)
+ #expect(payload.episodes.count == 3)
+ #expect(payload.shows.count == 3)
+}
+
+@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 == "Charade")
+ #expect(librariesByTitle["TV Shows"]?.type == .show)
+ #expect(librariesByTitle["TV Shows"]?.itemCount == 3)
+ #expect(librariesByTitle["TV Shows"]?.secondaryCount == 19)
+ #expect(librariesByTitle["TV Shows"]?.secondaryCountLabel == "seasons")
+ #expect(librariesByTitle["TV Shows"]?.latestItemTitle == "One Step Beyond")
+ #expect(librariesByTitle["Audiobooks"]?.type == .artist)
+ #expect(librariesByTitle["Audiobooks"]?.itemCount == 2)
+ #expect(librariesByTitle["Audiobooks"]?.secondaryCount == 3)
+ #expect(librariesByTitle["Audiobooks"]?.secondaryCountLabel == "albums")
+ #expect(librariesByTitle["Audiobooks"]?.latestItemTitle == "Bram Stoker")
+}
+
+@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.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 == "The War of the Worlds")
+ #expect(tommySession.parentThumb == "/mock/art/audiobooks/war-of-the-worlds.png")
+ #expect(tommySession.thumb == "/mock/art/audiobooks/war-of-the-worlds.png")
+ #expect(tommySession.player.product == "Prologue")
+ #expect(tommySession.player.title == "iPhone")
+}
+
+@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)
+}
+
+#endif
diff --git a/docs/screenshots/screen-grab-history.png b/docs/screenshots/screen-grab-history.png
index c39f4c2..36fe787 100644
Binary files a/docs/screenshots/screen-grab-history.png and b/docs/screenshots/screen-grab-history.png differ
diff --git a/docs/screenshots/screen-grab-libraries.png b/docs/screenshots/screen-grab-libraries.png
new file mode 100644
index 0000000..1c20d07
Binary files /dev/null and b/docs/screenshots/screen-grab-libraries.png differ
diff --git a/docs/screenshots/screen-grab-streams.png b/docs/screenshots/screen-grab-streams.png
index 2cdfed5..42e85db 100644
Binary files a/docs/screenshots/screen-grab-streams.png and b/docs/screenshots/screen-grab-streams.png differ
diff --git a/docs/screenshots/screen-grab-users.png b/docs/screenshots/screen-grab-users.png
new file mode 100644
index 0000000..7166484
Binary files /dev/null and b/docs/screenshots/screen-grab-users.png differ
diff --git a/script/build_and_run.sh b/script/build_and_run.sh
index fda2696..a4cfee4 100755
--- a/script/build_and_run.sh
+++ b/script/build_and_run.sh
@@ -3,6 +3,7 @@ set -euo pipefail
MODE="run"
ENABLE_CODESIGN=0
+ENABLE_MOCK_RUNTIME=0
APP_NAME="PlexBar"
BUNDLE_ID="com.crapshack.PlexBar"
MIN_SYSTEM_VERSION="26.0"
@@ -39,11 +40,14 @@ parse_args() {
--codesign)
ENABLE_CODESIGN=1
;;
+ --mock)
+ ENABLE_MOCK_RUNTIME=1
+ ;;
build|--build|run|debug|--debug|logs|--logs|telemetry|--telemetry|verify|--verify)
MODE="$1"
;;
*)
- echo "usage: $0 [--codesign] [build|run|--debug|--logs|--telemetry|--verify]" >&2
+ echo "usage: $0 [--codesign] [--mock] [build|run|--debug|--logs|--telemetry|--verify]" >&2
exit 2
;;
esac
@@ -193,11 +197,17 @@ SWIFT_BUILD_ARGS=(
-Xlinker "@executable_path/../Frameworks"
)
-swift build "${SWIFT_BUILD_ARGS[@]}"
BUILD_BIN_DIR="$(swift build "${SWIFT_BUILD_ARGS[@]}" --show-bin-path)"
-BUILD_BINARY="$BUILD_BIN_DIR/$APP_NAME"
RESOURCE_BUNDLE_NAME="${APP_NAME}_${APP_NAME}.bundle"
RESOURCE_BUNDLE_SOURCE="$BUILD_BIN_DIR/$RESOURCE_BUNDLE_NAME"
+BUILD_BINARY="$BUILD_BIN_DIR/$APP_NAME"
+
+# SwiftPM leaves stale files behind in processed resource bundles when resources
+# are removed from the manifest. Clear the generated bundle before rebuilding so
+# dist apps cannot accidentally ship old debug-only assets.
+rm -rf "$RESOURCE_BUNDLE_SOURCE"
+
+swift build "${SWIFT_BUILD_ARGS[@]}"
rm -rf "$APP_BUNDLE"
mkdir -p "$APP_MACOS" "$APP_FRAMEWORKS" "$APP_RESOURCES"
@@ -293,7 +303,11 @@ if [[ "$ENABLE_CODESIGN" -eq 1 ]]; then
fi
open_app() {
- /usr/bin/open -n "$APP_BUNDLE"
+ if [[ "$ENABLE_MOCK_RUNTIME" -eq 1 ]]; then
+ /usr/bin/open -n "$APP_BUNDLE" --args --mock
+ else
+ /usr/bin/open -n "$APP_BUNDLE"
+ fi
}
case "$MODE" in
@@ -304,7 +318,11 @@ case "$MODE" in
open_app
;;
--debug|debug)
- lldb -- "$APP_BINARY"
+ if [[ "$ENABLE_MOCK_RUNTIME" -eq 1 ]]; then
+ lldb -- "$APP_BINARY" --mock
+ else
+ lldb -- "$APP_BINARY"
+ fi
;;
--logs|logs)
open_app
@@ -320,7 +338,7 @@ case "$MODE" in
pgrep -x "$APP_NAME" >/dev/null
;;
*)
- echo "usage: $0 [build|run|--debug|--logs|--telemetry|--verify]" >&2
+ echo "usage: $0 [--mock] [build|run|--debug|--logs|--telemetry|--verify]" >&2
exit 2
;;
esac