diff --git a/App.js b/App.js index e7b481f..37a9d6b 100644 --- a/App.js +++ b/App.js @@ -8,6 +8,7 @@ import 'react-native-url-polyfill/auto'; import { Ionicons } from '@expo/vector-icons'; +import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api/playstate-api'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { NavigationContainer } from '@react-navigation/native'; import { Asset } from 'expo-asset'; @@ -20,7 +21,7 @@ import { observer } from 'mobx-react-lite'; import { AsyncTrunk } from 'mobx-sync-lite'; import PropTypes from 'prop-types'; import React, { useContext, useEffect, useState } from 'react'; -import { useColorScheme } from 'react-native'; +import { Alert, useColorScheme } from 'react-native'; import { ThemeContext, ThemeProvider } from 'react-native-elements'; import { SafeAreaProvider } from 'react-native-safe-area-context'; @@ -140,7 +141,27 @@ const App = observer(({ skipLoadingScreen }) => { download.isDownloading = false; } catch (e) { console.error('[App] Download failed', e); + Alert.alert('Download Failed', `"${download.title}" failed to download.`); + + // TODO: If a download fails, we should probably remove it from the queue download.isDownloading = false; + } finally { + const serverUrl = download.serverUrl.endsWith('/') ? download.serverUrl.slice(0, -1) : download.serverUrl; + const api = rootStore.sdk.createApi(serverUrl); + console.log('[App] Reporting download stopped', download.sessionId); + getPlaystateApi(api) + .reportPlaybackStopped({ + playbackStopInfo: { + PlaySessionId: download.sessionId + } + }, { + query: { + api_key: download.apiKey + } + }) + .catch(err => { + console.error('[App] Failed reporting download stopped', err.response || err.request || err.message); + }); } }; diff --git a/models/DownloadModel.ts b/models/DownloadModel.ts index 6b5465f..94fcb4b 100644 --- a/models/DownloadModel.ts +++ b/models/DownloadModel.ts @@ -7,6 +7,7 @@ import * as FileSystem from 'expo-file-system'; import { computed, decorate, observable } from 'mobx'; import { ignore } from 'mobx-sync-lite'; +import { v4 as uuidv4 } from 'uuid'; export default class DownloadModel { isComplete = false @@ -15,6 +16,8 @@ export default class DownloadModel { apiKey: string itemId: string + /** The "play" session ID for reporting a download has stopped. */ + sessionId = uuidv4() serverId: string serverUrl: string @@ -61,6 +64,7 @@ export default class DownloadModel { const streamParams = new URLSearchParams({ deviceId, api_key: this.apiKey, + playSessionId: this.sessionId, // TODO: add mediaSourceId to support alternate media versions videoCodec: 'hevc,h264', audioCodec: 'aac,mp3,ac3,eac3,flac,alac', @@ -79,6 +83,7 @@ decorate(DownloadModel, { isNew: observable, apiKey: observable, itemId: observable, + sessionId: observable, serverId: observable, serverUrl: observable, title: observable, diff --git a/package-lock.json b/package-lock.json index cc144c7..a194917 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2126,6 +2126,41 @@ "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", "dev": true }, + "@jellyfin/sdk": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.7.0.tgz", + "integrity": "sha512-GNoGv+2qY+xK7WpO7sUUNpZvzgN7RwXMyOhIy9mE/LdDSr6bqZHwrzT1Pv0+vUW7Epw67bwIMWuYivyBYejEHw==", + "requires": { + "axios": "0.27.2", + "compare-versions": "5.0.1" + }, + "dependencies": { + "axios": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", + "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==", + "requires": { + "follow-redirects": "^1.14.9", + "form-data": "^4.0.0" + } + }, + "compare-versions": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-5.0.1.tgz", + "integrity": "sha512-v8Au3l0b+Nwkp4G142JcgJFh1/TUhdxut7wzD1Nq1dyp5oa3tXaqb03EXOAB6jS4gMlalkjAUPZBMiAfKUixHQ==" + }, + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + } + } + }, "@jest/console": { "version": "26.6.2", "resolved": "https://registry.npmjs.org/@jest/console/-/console-26.6.2.tgz", @@ -3694,6 +3729,12 @@ } } }, + "@types/uuid": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", + "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==", + "dev": true + }, "@types/webpack": { "version": "4.41.33", "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.33.tgz", @@ -9320,8 +9361,7 @@ "follow-redirects": { "version": "1.15.2", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", - "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", - "dev": true + "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==" }, "fontfaceobserver": { "version": "2.3.0", diff --git a/package.json b/package.json index 272dd58..e254a4f 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "Android >= 5" ], "dependencies": { + "@jellyfin/sdk": "^0.7.0", "@react-native-async-storage/async-storage": "~1.17.3", "@react-native-community/masked-view": "0.1.10", "@react-navigation/bottom-tabs": "^6.0.7", @@ -82,6 +83,7 @@ "@testing-library/react-native": "^7.2.0", "@types/jest": "^27.0.2", "@types/react": "~17.0.21", + "@types/uuid": "^8.3.4", "@typescript-eslint/eslint-plugin": "^5.16.0", "@typescript-eslint/parser": "^4.33.0", "babel-preset-expo": "~9.1.0", diff --git a/stores/RootStore.js b/stores/RootStore.js index ad7118b..2a8c703 100644 --- a/stores/RootStore.js +++ b/stores/RootStore.js @@ -7,10 +7,14 @@ // polyfill crypto.getRandomValues import 'react-native-get-random-values'; -import { action, decorate, observable } from 'mobx'; +import { Jellyfin } from '@jellyfin/sdk'; +import Constants from 'expo-constants'; +import { action, computed, decorate, observable } from 'mobx'; import { ignore } from 'mobx-sync-lite'; import { v4 as uuidv4 } from 'uuid'; +import { getAppName, getSafeDeviceName } from '../utils/Device'; + import DownloadStore from './DownloadStore'; import MediaStore from './MediaStore'; import ServerStore from './ServerStore'; @@ -47,6 +51,19 @@ export default class RootStore { serverStore = new ServerStore() settingStore = new SettingStore() + get sdk() { + return (new Jellyfin({ + clientInfo: { + name: getAppName(), + version: Constants.nativeAppVersion + }, + deviceInfo: { + name: getSafeDeviceName(), + id: this.deviceId + } + })); + } + reset() { this.deviceId = uuidv4(); @@ -69,5 +86,6 @@ decorate(RootStore, { isFullscreen: [ ignore, observable ], isReloadRequired: [ ignore, observable ], didPlayerCloseManually: [ ignore, observable ], + sdk: computed, reset: action });