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.

- Active streams - Playback history + Active streams + Playback history + User activity + Library overview

## 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