presence.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933
  1. /*
  2. * The interfaces may have some things missing,
  3. * I've tried to set as many properties as I could find.
  4. */
  5. //#region Interfaces
  6. interface ApiClient {
  7. enableAutomaticBitrateDetection: boolean;
  8. enableAutomaticNetworking: boolean;
  9. lastDetectedBitrate: number;
  10. lastDetectedBitrateTime: number; // timestamp
  11. lastFetch: number; // timestamp
  12. lastPlaybackProgressReport: number;
  13. lastPlaybackProgressReportTicks: number;
  14. manualAddressOnly: boolean;
  15. _appName: string;
  16. _appVersion: string;
  17. _currentUser: {
  18. Configuration: {
  19. AudioLanguagePreference: string;
  20. DisplayCollectionsView: boolean;
  21. DisplayMissingEpisodes: boolean;
  22. EnableLocalPassword: boolean;
  23. EnableNextEpisodeAutoPlay: boolean;
  24. HidePlayedInLatest: boolean;
  25. OrderedViews: string[];
  26. PlayDefaultAudioTrack: boolean;
  27. RememberAudioSelections: boolean;
  28. RememberSubtitleSelections: boolean;
  29. SubtitleLanguagePreference: string;
  30. SubtitleMode: string;
  31. };
  32. HasConfiguredEasyPassword: boolean;
  33. HasConfiguredPassword: boolean;
  34. HasPassword: boolean;
  35. Id: string;
  36. LastActivityDate: string; // date, ex: "2020-05-30T21:51:23.9732162Z"
  37. LastLoginDate: string; // date, ex: "2020-05-30T21:51:23.9732162Z"
  38. Name: string;
  39. Policy: {
  40. AuthenticationProviderId: string;
  41. EnableAllChannels: boolean;
  42. EnableAllDevices: boolean;
  43. EnableAllFolders: boolean;
  44. EnableAudioPlaybackTranscoding: boolean;
  45. EnableContentDeletion: boolean;
  46. EnableContentDownloading: boolean;
  47. EnableLiveTvAccess: boolean;
  48. EnableLiveTvManagement: boolean;
  49. EnableMediaConversion: boolean;
  50. EnableMediaPlayback: boolean;
  51. EnablePlaybackRemuxing: boolean;
  52. EnablePublicSharing: boolean;
  53. EnableRemoteAccess: boolean;
  54. EnableRemoteControlOfOtherUsers: boolean;
  55. EnableSharedDeviceControl: boolean;
  56. EnableSyncTranscoding: boolean;
  57. EnableUserPreferenceAccess: boolean;
  58. EnableVideoPlaybackTranscoding: boolean;
  59. ForceRemoteSourceTranscoding: boolean;
  60. InvalidLoginAttemptCount: boolean;
  61. IsAdministrator: boolean;
  62. IsDisabled: boolean;
  63. IsHidden: boolean;
  64. LoginAttemptsBeforeLockout: number;
  65. PasswordResetProviderId: string;
  66. RemoteClientBitrateLimit: number;
  67. };
  68. PrimaryImageAspectRatio: number;
  69. PrimaryImageTag: string;
  70. ServerId: string;
  71. };
  72. _deviceId: string;
  73. _deviceName: string;
  74. _endPointInfo: {
  75. IsInNetwork: boolean;
  76. IsLocal: boolean;
  77. };
  78. _serverAddress: string;
  79. _serverInfo: Server;
  80. _serverVersion: string;
  81. _webSocket: {
  82. binaryType: string;
  83. bufferedAmount: number;
  84. extensions: string;
  85. protocol: string;
  86. readyState: number;
  87. url: string;
  88. };
  89. }
  90. interface MediaStream {
  91. Codec: string;
  92. TimeBase: string;
  93. CodecTimeBase: string;
  94. VideoRange: string;
  95. DisplayTitle: string;
  96. IsInterlaced: boolean;
  97. BitRate: number;
  98. RefFrames: number;
  99. IsDefault: boolean;
  100. IsForced: boolean;
  101. Height: number;
  102. Width: number;
  103. AverageFrameRate: number;
  104. RealFrameRate: number;
  105. Profile: string;
  106. Type: string;
  107. AspectRatio: string;
  108. Index: number;
  109. IsExternal: boolean;
  110. IsTextSubtitleStream: boolean;
  111. SupportsExternalStream: boolean;
  112. PixelFormat: string;
  113. Level: number;
  114. }
  115. interface MediaSource {
  116. Protocol: string;
  117. Id: string;
  118. Path: string;
  119. Type: string;
  120. Container: string;
  121. Size: number;
  122. Name: string;
  123. IsRemote: boolean;
  124. ETag: string;
  125. RunTimeTicks: number;
  126. ReadAtNativeFramerate: boolean;
  127. IgnoreDts: boolean;
  128. IgnoreIndex: boolean;
  129. GenPtsInput: boolean;
  130. SupportsTranscoding: true;
  131. SupportsDirectStream: boolean;
  132. SupportsDirectPlay: boolean;
  133. IsInfiniteStream: boolean;
  134. RequiresOpening: boolean;
  135. RequiresClosing: boolean;
  136. RequiresLooping: boolean;
  137. SupportsProbing: true;
  138. VideoType: string;
  139. MediaStreams: MediaStream[];
  140. MediaAttachments: [];
  141. Formats: [];
  142. Bitrate: number;
  143. RequiredHttpHeaders: unknown;
  144. DefaultAudioStreamIndex: number;
  145. }
  146. interface ExternalUrl {
  147. Name: string;
  148. Url: string;
  149. }
  150. interface Person {
  151. Name: string;
  152. Id: string;
  153. Role: string;
  154. Type: string;
  155. PrimaryImageTag: string;
  156. }
  157. interface UserData {
  158. PlaybackPositionTicks: number;
  159. PlayCount: number;
  160. IsFavorite: boolean;
  161. LastPlayedDate: string; // date, ex: "2020-05-30T21:51:23.9732162Z"
  162. Played: boolean;
  163. Key: string;
  164. }
  165. interface Chapter {
  166. StartPositionTicks: number;
  167. Name: string;
  168. ImageDateModified: string; // date, ex: "2020-05-30T21:51:23.9732162Z"
  169. }
  170. interface MediaInfo {
  171. AlbumArtist: string;
  172. AlbumArtists: { Name: string; Id: string }[];
  173. AlbumId: string;
  174. AlbumPrimaryImageTag: string;
  175. ArtistsItems: { Name: string; Id: string }[];
  176. Artists: string[];
  177. Name: string;
  178. OriginalTitle: string;
  179. ServerId: string;
  180. Id: string;
  181. Etag: string;
  182. DateCreated: string; // date, ex: "2020-05-30T21:51:23.9732162Z"
  183. CanDelete: boolean;
  184. CanDownload: boolean;
  185. HasSubtitles: boolean;
  186. Container: string;
  187. SortName: string;
  188. PremiereDate: string; // date, ex: "2020-05-30T21:51:23.9732162Z"
  189. ExternalUrls: ExternalUrl[];
  190. MediaSources: MediaSource[];
  191. Path: string;
  192. EnableMediaSourceDisplay: boolean;
  193. Overview: string;
  194. // TagLines: Array;
  195. // Genres: Array;
  196. CommunityRating: number;
  197. RunTimeTicks: number;
  198. PlayAccess: string;
  199. ProductionYear: number;
  200. IndexNumber: number;
  201. ParentIndexNumber: number;
  202. // RemoteTrailers: Array;
  203. ProviderIds: {
  204. Tvdb?: number;
  205. };
  206. IsHD: boolean;
  207. IsFolder: boolean;
  208. ParentId: string;
  209. Type:
  210. | "Audio"
  211. | "MusicAlbum"
  212. | "MusicArtist"
  213. | "Movie"
  214. | "Series"
  215. | "Season"
  216. | "Episode"
  217. | "TvChannel"
  218. | "Person";
  219. People: Person[];
  220. // Studios: Array;
  221. // GenreItems: Array;
  222. ParentBackdropItemId: string;
  223. ParentBackdropImageTags: string[];
  224. LocalTrailerCount: number;
  225. UserData: UserData;
  226. RecursiveItemCount: number;
  227. Status: string;
  228. SeriesName: string;
  229. SeriesId: string;
  230. SeasonId: string;
  231. SpecialFeatureCount: number;
  232. DisplayPreferencesId: string;
  233. // Tags: Array;
  234. PrimaryImageAspectRatio: number;
  235. SeriesPrimaryImageTag: string;
  236. SeasonName: string;
  237. MediaStreams: MediaStream[];
  238. VideoType: string;
  239. ImageTags: {
  240. Primary: string;
  241. };
  242. // BackdropImageTags: Array;
  243. // ScreenshotImageTags: Array;
  244. SeriesStudio: string;
  245. Chapters: Chapter[];
  246. LocationType: string;
  247. MediaType: string;
  248. // LockedFields: Array;
  249. LockData: boolean;
  250. Width: number;
  251. Height: number;
  252. }
  253. interface Server {
  254. AccessToken: string;
  255. DateLastAccessed: number; // timestamp
  256. Id: string;
  257. IsLocalServer: boolean;
  258. LastConnectionMode: number;
  259. LocalAddress: string;
  260. ManualAddress: string;
  261. Name: string;
  262. RemoteAddress: string;
  263. Type: "Server";
  264. UserId: string;
  265. manualAddressOnly: boolean;
  266. }
  267. // #endregion
  268. const enum Assets {
  269. logo = "https://cdn.rcd.gg/PreMiD/websites/J/Jellyfin/assets/logo.png",
  270. }
  271. const JELLYFIN_URL = "jellyfin.org",
  272. // all the presence art assets uploaded to discord
  273. presenceData: PresenceData = {
  274. largeImageKey: Assets.logo,
  275. startTimestamp: Math.floor(Date.now() / 1000),
  276. };
  277. let ApiClient: ApiClient,
  278. presence: Presence,
  279. wasLogin = false;
  280. /**
  281. * Obtain the base name Url of the server
  282. */
  283. function jellyfinBasenameUrl(): string {
  284. const { pathname } = location;
  285. return `${location.origin}${pathname.replace(
  286. pathname.split("/").slice(-2).join("/"),
  287. ""
  288. )}`;
  289. }
  290. /**
  291. * Obtain the url of the primary image of a media given its id
  292. */
  293. function mediaPrimaryImage(mediaInfo: MediaInfo): string {
  294. let mediaId: string;
  295. switch (mediaInfo.Type) {
  296. case "Episode":
  297. mediaId = mediaInfo.SeriesId;
  298. break;
  299. case "Audio":
  300. mediaId = mediaInfo.AlbumId;
  301. break;
  302. default:
  303. mediaId = mediaInfo.Id;
  304. }
  305. return `${jellyfinBasenameUrl()}Items/${mediaId}/Images/Primary?fillHeight=256&fillWidth=256`;
  306. }
  307. /**
  308. * Handle the presence when the audio player is active
  309. */
  310. async function handleAudioPlayback(): Promise<void> {
  311. const regexResult = /\/Audio\/(\w+)\/universal/.exec(
  312. document.querySelector("audio").src
  313. );
  314. if (!regexResult) {
  315. presence.error("Could not obtain audio itemId");
  316. return;
  317. }
  318. await setPresenceByMediaId(regexResult[1]);
  319. }
  320. /**
  321. * Handle the presence while the user is in the official website
  322. */
  323. function handleOfficialWebsite(): void {
  324. presenceData.details = "At jellyfin.org";
  325. switch (location.pathname) {
  326. case "/":
  327. presenceData.state = "On landing page";
  328. break;
  329. case "/posts/":
  330. presenceData.state = "Reading the latest posts";
  331. presenceData.smallImageKey = Assets.Reading;
  332. break;
  333. case "/clients/":
  334. presenceData.state = "Checking clients";
  335. presenceData.smallImageKey = Assets.Search;
  336. break;
  337. case "/downloads/":
  338. presenceData.state = "On downloads";
  339. presenceData.smallImageKey = Assets.Downloading;
  340. break;
  341. case "/contribute/":
  342. presenceData.state = "Learning how to contribute";
  343. break;
  344. case "/contact/":
  345. presenceData.state = "On contact page";
  346. break;
  347. default:
  348. // reading the docs
  349. if (location.pathname.indexOf("/docs/") === 0) {
  350. presenceData.state = `Reading the docs: ${document.title
  351. .split("|")[0]
  352. .trim()}`;
  353. presenceData.smallImageKey = Assets.Reading;
  354. }
  355. }
  356. }
  357. /**
  358. * Obtain the authenticated user id
  359. */
  360. function getUserId(): string {
  361. try {
  362. return ApiClient._currentUser.Id;
  363. } catch (e) {
  364. const servers: Server[] = JSON.parse(
  365. localStorage.getItem("jellyfin_credentials")
  366. ).Servers;
  367. return (
  368. servers.length === 1
  369. ? servers[0]
  370. : servers.find(
  371. (s: Server) =>
  372. s.Id ===
  373. new URLSearchParams(location.hash.split("?")[1]).get("serverId")
  374. )
  375. ).UserId;
  376. }
  377. }
  378. /**
  379. * Cache performed mediaInfo
  380. */
  381. const mediaInfoCache = new Map<string, MediaInfo>();
  382. /**
  383. * Obtain media info given an itemId
  384. */
  385. async function obtainMediaInfo(itemId: string): Promise<MediaInfo> {
  386. if (mediaInfoCache.has(itemId)) return mediaInfoCache.get(itemId);
  387. const res = await fetch(
  388. `${jellyfinBasenameUrl()}Users/${getUserId()}/Items/${itemId}`,
  389. {
  390. credentials: "include",
  391. headers: {
  392. "x-emby-authorization":
  393. `MediaBrowser Client="${ApiClient._appName}",` +
  394. `Device="${ApiClient._deviceName}",` +
  395. `DeviceId="${ApiClient._deviceId}",` +
  396. `Version="${ApiClient._appVersion}",` +
  397. `Token="${ApiClient._serverInfo.AccessToken}"`,
  398. },
  399. }
  400. ),
  401. mediaInfo: MediaInfo = await res.json();
  402. mediaInfoCache.set(itemId, mediaInfo);
  403. return mediaInfoCache.get(itemId);
  404. }
  405. /**
  406. * Cache performed media searches
  407. */
  408. const searchMediaCache = new Map<string, MediaInfo[]>(),
  409. uploadedMediaCache = new Map<string, string>();
  410. /**
  411. * Search Movie and Series given a term
  412. */
  413. async function searchMedia(searchTerm: string): Promise<MediaInfo[]> {
  414. if (searchMediaCache.has(searchTerm)) return searchMediaCache.get(searchTerm);
  415. if (/-[ ]S[0-9]+:E[0-9]+[ ]-/.test(searchTerm))
  416. searchTerm = searchTerm.split(" - ").pop();
  417. // The API does not like the year in the search term
  418. searchTerm = searchTerm.replace(/\([0-9]{4}\)/, "").trim();
  419. const res = await fetch(
  420. `${jellyfinBasenameUrl()}Users/${getUserId()}/Items/?searchTerm=${searchTerm}` +
  421. "&IncludePeople=false&IncludeMedia=true&IncludeGenres=false&IncludeStudios=false" +
  422. "&IncludeArtists=false&IncludeItemTypes=Movie,Episode&Limit=3" +
  423. "&Fields=PrimaryImageAspectRatio%2CCanDelete%2CBasicSyncInfo%2CMediaSourceCount" +
  424. "&Recursive=true&EnableTotalRecordCount=false&ImageTypeLimit=1",
  425. {
  426. credentials: "include",
  427. headers: {
  428. "x-emby-authorization":
  429. `MediaBrowser Client="${ApiClient._appName}",` +
  430. `Device="${ApiClient._deviceName}",` +
  431. `DeviceId="${ApiClient._deviceId}",` +
  432. `Version="${ApiClient._appVersion}",` +
  433. `Token="${ApiClient._serverInfo.AccessToken}"`,
  434. },
  435. }
  436. ),
  437. resJson = await res.json();
  438. searchMediaCache.set(searchTerm, resJson.Items);
  439. return searchMediaCache.get(searchTerm);
  440. }
  441. /**
  442. * Handles the presence when the user is using the video player
  443. */
  444. async function handleVideoPlayback(): Promise<void> {
  445. if (!document.querySelector("#videoOsdPage")) {
  446. // elements not loaded yet
  447. return;
  448. }
  449. // title on the header
  450. const [mediaInfo] = await searchMedia(
  451. document.querySelector<HTMLHeadingElement>("h3.pageTitle").textContent
  452. );
  453. if (mediaInfo) {
  454. await setPresenceByMediaId(mediaInfo.Id);
  455. return;
  456. }
  457. // display generic info
  458. presenceData.details = "Watching:";
  459. presenceData.state = "Unknown Content";
  460. if (!presenceData.state) delete presenceData.state;
  461. }
  462. /**
  463. * Handle the presence when the user is playing back content remotely
  464. */
  465. async function handleRemotePlayback(): Promise<void> {
  466. const [, mediaId] = /\/Items\/(\w+)\/Images/.exec(
  467. document.querySelector<HTMLDivElement>(".nowPlayingImage").style
  468. .backgroundImage
  469. );
  470. await setPresenceByMediaId(mediaId);
  471. }
  472. /**
  473. * Handle the presence when the user is viewing the details of an item
  474. */
  475. async function handleItemDetails(): Promise<void> {
  476. const data = await obtainMediaInfo(
  477. new URLSearchParams(location.hash.split("?")[1]).get("id")
  478. );
  479. if (!data) {
  480. presenceData.details = "Browsing details of an item";
  481. presenceData.state = "Could not get item details";
  482. } else if (typeof data === "string") return;
  483. else {
  484. presenceData.details = `Browsing details of: ${data.Name}`;
  485. switch (data.Type) {
  486. case "Movie":
  487. presenceData.state = `${data.Type} ─ ${data.OriginalTitle} (${data.ProductionYear})`;
  488. break;
  489. case "Series":
  490. presenceData.state = `${data.Type} ─ (${data.Status})`;
  491. break;
  492. case "Season":
  493. presenceData.state = `${data.Type} ─ ${data.SeriesName}`;
  494. break;
  495. case "Episode":
  496. presenceData.state = `${data.Type} ─ ${data.SeriesName} - ${data.SeasonName}`;
  497. break;
  498. case "Person": {
  499. let description = "Description not available";
  500. if (data.Overview) {
  501. description =
  502. data.Overview.substring(0, 40) +
  503. (data.Overview.length > 40 ? "..." : "");
  504. }
  505. presenceData.state = `${data.Type} ─ ${description}`;
  506. break;
  507. }
  508. case "MusicAlbum":
  509. presenceData.state = `${data.Type} ─ ${data.RecursiveItemCount} songs`;
  510. break;
  511. case "MusicArtist":
  512. case "TvChannel":
  513. presenceData.state = `${data.Type} ─ No further information available`;
  514. break;
  515. default:
  516. presenceData.state = "No further information available";
  517. }
  518. if (await presence.getSetting("showThumbnails"))
  519. presenceData.largeImageKey = mediaPrimaryImage(data);
  520. }
  521. }
  522. /**
  523. * Sets a presence based on the given multimedia mediaId
  524. */
  525. async function setPresenceByMediaId(mediaId: string): Promise<void> {
  526. const mediaInfo = await obtainMediaInfo(mediaId);
  527. let title: string, subtitle: string;
  528. switch (mediaInfo.Type) {
  529. case "Audio":
  530. presenceData.type = ActivityType.Listening;
  531. title = `Listening to ${mediaInfo.Name ?? "Unknown title"}`;
  532. subtitle = `By ${mediaInfo.AlbumArtist ?? "Unknown artist"}`;
  533. break;
  534. case "Movie":
  535. case "Series":
  536. title = `Watching ${mediaInfo.Type}`;
  537. subtitle = mediaInfo.Name;
  538. break;
  539. case "Episode":
  540. title = `Watching: ${mediaInfo.SeriesName}`;
  541. subtitle = `${/S[0-9]+:E[0-9]+/.exec(
  542. document.querySelector<HTMLHeadingElement>(".pageTitle").textContent
  543. )} - ${mediaInfo.Name}`;
  544. break;
  545. case "TvChannel":
  546. presenceData.smallImageKey = Assets.Live;
  547. presenceData.smallImageText = "Live TV";
  548. break;
  549. default:
  550. title = `Watching ${mediaInfo.Type}`;
  551. subtitle = mediaInfo.Name;
  552. }
  553. if (presenceData.type !== ActivityType.Listening)
  554. presenceData.type = ActivityType.Watching;
  555. if (await presence.getSetting("showThumbnails"))
  556. presenceData.largeImageKey = mediaPrimaryImage(mediaInfo);
  557. if (mediaInfo.Type !== "TvChannel") {
  558. const mediaElement =
  559. document.querySelector<HTMLMediaElement>("audio, video"),
  560. paused = mediaElement
  561. ? mediaElement.paused
  562. : document
  563. .querySelector<HTMLSpanElement>(
  564. ".nowPlayingBar .playPauseButton span"
  565. )
  566. .classList.contains("play_arrow");
  567. if (paused) {
  568. presenceData.smallImageKey = Assets.Pause;
  569. presenceData.smallImageText = "Paused";
  570. delete presenceData.endTimestamp;
  571. } else {
  572. presenceData.smallImageKey = Assets.Play;
  573. presenceData.smallImageText = "Playing";
  574. // TODO: worth setting timestamps on remote playback? Requires WS connection
  575. if (mediaElement && (await presence.getSetting("showMediaTimestamps"))) {
  576. [presenceData.startTimestamp, presenceData.endTimestamp] =
  577. presence.getTimestampsfromMedia(mediaElement);
  578. }
  579. }
  580. }
  581. presenceData.details = title;
  582. presenceData.state = subtitle;
  583. if (!presenceData.state) delete presenceData.state;
  584. }
  585. /**
  586. * Suspend execution of code for an interval, see <https://manpage.me/?q=sleep>
  587. */
  588. function sleep(ms: number): Promise<void> {
  589. return new Promise(res => {
  590. setTimeout(res, ms);
  591. });
  592. }
  593. /**
  594. * Refreshes the ApiClient object
  595. */
  596. async function loggedIn(): Promise<void> {
  597. let apiClient: ApiClient;
  598. do {
  599. await sleep(125);
  600. apiClient = await presence.getPageletiable<ApiClient>("ApiClient");
  601. } while (!apiClient._serverInfo.AccessToken);
  602. ApiClient = apiClient;
  603. }
  604. /**
  605. * Handle the presence while the user is in the web client
  606. */
  607. async function handleWebClient(): Promise<void> {
  608. const audioElement = document.body.querySelector<HTMLAudioElement>("audio"),
  609. nowPlayingBar = document.querySelector(".nowPlayingBar");
  610. // audio player active
  611. if (
  612. audioElement &&
  613. audioElement.classList.contains("mediaPlayerAudio") &&
  614. audioElement.src
  615. ) {
  616. await handleAudioPlayback();
  617. return;
  618. } else if (
  619. nowPlayingBar &&
  620. !nowPlayingBar.classList.contains("nowPlayingBar-hidden")
  621. ) {
  622. await handleRemotePlayback();
  623. return;
  624. }
  625. presenceData.details = "At web client";
  626. // obtain the path, on the example would return "login.html"
  627. // https://media.domain.tld/web/index.html#!/login.html?serverid=randomserverid
  628. const path = location.hash.split("?")[0].substring(2);
  629. if (path === "login.html") {
  630. wasLogin = true;
  631. presenceData.state = "Logging in";
  632. } else if (wasLogin) {
  633. loggedIn();
  634. wasLogin = false;
  635. }
  636. switch (path) {
  637. case "home.html":
  638. presenceData.state = "At home";
  639. break;
  640. case "search.html":
  641. presenceData.state = "Searching";
  642. presenceData.smallImageKey = Assets.Search;
  643. break;
  644. // user preferences
  645. case "mypreferencesmenu.html":
  646. case "myprofile.html": // profile
  647. case "mypreferencesdisplay.html": // display
  648. case "mypreferenceshome.html": // home
  649. case "mypreferencesplayback.html": // playback
  650. case "mypreferencessubtitles.html": // subtitles
  651. presenceData.state = "On user preferences";
  652. break;
  653. // admin dashboard
  654. case "dashboard.html": // server section
  655. case "dashboardgeneral.html": // general
  656. case "userprofiles.html": // user profiles
  657. case "useredit.html": // editing user profile
  658. case "userlibraryaccess.html": // editing user profile > library access
  659. case "userparentalcontrol.html": // editing user profile > parental control
  660. case "userpassword.html": // editing user profile > password
  661. case "library.html": // managing library
  662. case "librarydisplay.html": // library display settings
  663. case "metadataimages.html": // library metadata settings
  664. case "metadatanfo.html": // library NFO settings
  665. case "encodingsettings.html": // encoding settings > transcoding
  666. case "playbackconfiguration.html": // encoding settings > resume
  667. case "streamingsettings.html": // encoding settings > streaming
  668. case "devices.html": // devices
  669. case "device.html": // editing device
  670. case "serveractivity.html": // server activity
  671. case "dlnasettings.html": // dlna settings > settings
  672. case "dlnaprofiles.html": // dlna settings > profiles
  673. case "dlnaprofile.html": // dlna settings > add profile
  674. case "livetvstatus.html": // manage live tv
  675. case "livetvtuner.html": // add/manage tv tuner
  676. case "livetvguideprovider.html": // add/manage tv guide provider
  677. case "livetvsettings.html": // live tv settings (dvr) // advanced section
  678. case "networking.html": // networking
  679. case "apikeys.html": // api keys
  680. case "log.html": // logs
  681. case "notificationsettings.html": // notification settings
  682. case "installedplugins.html": // plugins
  683. case "availableplugins.html": // plugins catalog
  684. case "scheduledtasks.html": // scheduled tasks
  685. case "configurationpage": // plugins configuration page
  686. presenceData.state = "On admin dashboard";
  687. break;
  688. case "movies.html":
  689. presenceData.state = "Browsing movies";
  690. break;
  691. case "tv.html":
  692. presenceData.state = "Browsing tv series";
  693. break;
  694. case "music.html":
  695. presenceData.state = "Browsing music";
  696. break;
  697. case "livetv.html":
  698. presenceData.state = "Browsing Live TV";
  699. break;
  700. case "edititemmetadata.html":
  701. presenceData.state = "Editing media metadata";
  702. break;
  703. case "details":
  704. await handleItemDetails();
  705. break;
  706. case "video":
  707. await handleVideoPlayback();
  708. break;
  709. case "nowplaying.html":
  710. presenceData.state = "Viewing the audio playlist";
  711. break;
  712. }
  713. }
  714. /**
  715. * Sets default values to the presenceData object
  716. */
  717. async function setDefaultsToPresence(): Promise<void> {
  718. presenceData.largeImageKey = Assets.logo;
  719. if (presenceData.smallImageKey) delete presenceData.smallImageKey;
  720. if (presenceData.smallImageText) delete presenceData.smallImageText;
  721. if (presenceData.state) delete presenceData.state;
  722. if (presenceData.startTimestamp) delete presenceData.startTimestamp;
  723. if (isNaN(presenceData.endTimestamp as number))
  724. delete presenceData.endTimestamp;
  725. if ((await presence.getSetting<boolean>("showTimestamps")) === false)
  726. delete presenceData.startTimestamp;
  727. }
  728. /**
  729. * Initializes the ApiClient object
  730. */
  731. async function refreshApiClient(): Promise<void> {
  732. ApiClient ??= (
  733. await presence.getPageVariable<Record<"ApiClient", ApiClient>>("ApiClient")
  734. ).ApiClient;
  735. }
  736. /**
  737. * Imports the ApiClient variable and verifies that we are in the jellyfin web client
  738. */
  739. async function isJellyfinWebClient(): Promise<boolean> {
  740. if (!ApiClient) await refreshApiClient();
  741. if (
  742. ApiClient &&
  743. typeof ApiClient === "object" &&
  744. ApiClient._appName &&
  745. ApiClient._appName === "Jellyfin Web"
  746. )
  747. return true;
  748. return false;
  749. }
  750. /**
  751. * PreMiD tick function
  752. */
  753. async function updateData(): Promise<void> {
  754. await setDefaultsToPresence();
  755. let showPresence = false;
  756. // we are on the official jellyfin page
  757. if (location.host.toLowerCase() === JELLYFIN_URL) {
  758. showPresence = true;
  759. handleOfficialWebsite();
  760. // we are on the web client and has been verified
  761. } else if (await isJellyfinWebClient()) {
  762. showPresence = true;
  763. await handleWebClient();
  764. }
  765. // if jellyfin is detected init/update the presence status
  766. if (showPresence) {
  767. const largeImageKey = presenceData.largeImageKey as string;
  768. if (isPrivateIP(largeImageKey)) {
  769. if (uploadedMediaCache.has(largeImageKey))
  770. presenceData.largeImageKey = uploadedMediaCache.get(largeImageKey);
  771. else {
  772. await fetch(largeImageKey)
  773. .then(res => res.blob())
  774. .then(blob => {
  775. const reader = new FileReader();
  776. reader.readAsDataURL(blob);
  777. reader.onloadend = () => {
  778. const result = reader.result as string;
  779. uploadedMediaCache.set(largeImageKey, result);
  780. presenceData.largeImageKey = result;
  781. };
  782. });
  783. }
  784. }
  785. if (!presenceData.details) presence.setActivity();
  786. else presence.setActivity(presenceData);
  787. }
  788. }
  789. function isPrivateIP(ip: string): boolean {
  790. return /^http:\/\/(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.|127\.0\.0\.1|localhost)/.test(
  791. ip
  792. );
  793. }
  794. /**
  795. * Check if the presence should be initialized, if so start doing the magic
  796. */
  797. async function init(): Promise<void> {
  798. let validPage = false,
  799. infoMessage;
  800. // jellyfin website
  801. if (location.host === JELLYFIN_URL) {
  802. validPage = true;
  803. infoMessage = "Jellyfin website detected";
  804. // web client
  805. } else {
  806. try {
  807. for (const server of JSON.parse(
  808. localStorage.getItem("jellyfin_credentials")
  809. ).Servers) {
  810. // user has accessed in the last 30 seconds, should be enough for slow connections
  811. if (
  812. Date.now() - new Date(server.DateLastAccessed).getTime() <
  813. 30 * 1000
  814. ) {
  815. validPage = true;
  816. infoMessage = "Jellyfin web client detected";
  817. }
  818. }
  819. } catch (e) {
  820. validPage = false;
  821. }
  822. }
  823. if (validPage) {
  824. presence = new Presence({
  825. clientId: "669359568391766018",
  826. });
  827. presence.info(infoMessage);
  828. presence.on("UpdateData", updateData);
  829. }
  830. }
  831. init();