diff --git a/README.md b/README.md index 83519e4..c00e3dc 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,19 @@ # Reiverr TODO: -- [ ] Sonarr support -- [ ] Onboarding setup & sources -- [ ] Settings page -- [ ] Plex and Jellyfin sync +- [ ] Jellyfin video sync - [ ] Mass edit local files & show space left - [ ] Finish discover page +- [ ] Onboarding setup & sources +- [ ] Settings page +- [ ] Sonarr support - [ ] Event notifications & show indexer status +- [ ] Plex video sync + +FIX: +- [ ] YouTube trailer, hide on finish +- [ ] Finalize animations +- [ ] Improve continue watching Further ideas - [ ] Similar movies & shows, actor pages and recommendations diff --git a/package-lock.json b/package-lock.json index a099071..a918c5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "dependencies": { "@apollo/client": "^3.7.15", + "@jellyfin/sdk": "^0.7.0", "axios": "^1.4.0", "graphql": "^16.6.0", "hls.js": "^1.4.6", @@ -2601,6 +2602,24 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, + "node_modules/@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==", + "dependencies": { + "axios": "0.27.2", + "compare-versions": "5.0.1" + } + }, + "node_modules/@jellyfin/sdk/node_modules/axios": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", + "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==", + "dependencies": { + "follow-redirects": "^1.14.9", + "form-data": "^4.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", @@ -3999,6 +4018,11 @@ "node": ">=4.0.0" } }, + "node_modules/compare-versions": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-5.0.1.tgz", + "integrity": "sha512-v8Au3l0b+Nwkp4G142JcgJFh1/TUhdxut7wzD1Nq1dyp5oa3tXaqb03EXOAB6jS4gMlalkjAUPZBMiAfKUixHQ==" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", diff --git a/package.json b/package.json index c4d46f5..a5e544a 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "type": "module", "dependencies": { "@apollo/client": "^3.7.15", + "@jellyfin/sdk": "^0.7.0", "axios": "^1.4.0", "graphql": "^16.6.0", "hls.js": "^1.4.6", diff --git a/src/app.html b/src/app.html index b50429d..97d2ed4 100644 --- a/src/app.html +++ b/src/app.html @@ -6,6 +6,7 @@ + diff --git a/src/lib/jellyfin/jellyfin-state.ts b/src/lib/jellyfin/jellyfin-state.ts new file mode 100644 index 0000000..9ff3665 --- /dev/null +++ b/src/lib/jellyfin/jellyfin-state.ts @@ -0,0 +1,9 @@ +import { writable } from 'svelte/store'; + +async function fetchJellyfinState() { + return new Promise((resolve, reject) => { + setTimeout(() => resolve('true'), 2000); + }); +} + +export const jellyfinState = writable(fetchJellyfinState()); diff --git a/src/lib/jellyfin/jellyfin.ts b/src/lib/jellyfin/jellyfin.ts index af51944..cfd9542 100644 --- a/src/lib/jellyfin/jellyfin.ts +++ b/src/lib/jellyfin/jellyfin.ts @@ -2,6 +2,7 @@ import createClient from 'openapi-fetch'; import type { paths } from '$lib/jellyfin/jellyfin-types'; import { PUBLIC_JELLYFIN_API_KEY, PUBLIC_JELLYFIN_URL } from '$env/static/public'; import { request } from '$lib/utils'; +import type { DeviceProfile } from '$lib/jellyfin/playback-profiles'; export const JELLYFIN_DEVICE_ID = 'Reiverr Client'; export const JELLYFIN_USER_ID = '75dcb061c9404115a7acdc893ea6bbbc'; @@ -42,16 +43,24 @@ export const getJellyfinItemByTmdbId = (tmdbId: string) => } }).then((r) => r.data?.Items?.find((i) => i.ProviderIds?.Tmdb == tmdbId)); +export const getJellyfinItem = (itemId: string) => + JellyfinApi.get('/Users/{userId}/Items/{itemId}', { + params: { + path: { + itemId, + userId: JELLYFIN_USER_ID + } + } + }).then((r) => r.data); + export const requestJellyfinItemByTmdbId = () => request((tmdbId: string) => getJellyfinItemByTmdbId(tmdbId)); -export const getJellyfinPlaybackInfo = () => request(fetchJellyfinPlaybackUrl); - -export const fetchJellyfinPlaybackUrl = (id: string) => +export const getJellyfinPlaybackInfo = (itemId: string, playbackProfile: DeviceProfile) => JellyfinApi.post('/Items/{itemId}/PlaybackInfo', { params: { path: { - itemId: id + itemId: itemId }, query: { userId: JELLYFIN_USER_ID, @@ -61,314 +70,59 @@ export const fetchJellyfinPlaybackUrl = (id: string) => } }, body: { - DeviceProfile: { - CodecProfiles: [ - { - Codec: 'aac', - Conditions: [ - { - Condition: 'Equals', - IsRequired: false, - Property: 'IsSecondaryAudio', - Value: 'false' - } - ], - Type: 'VideoAudio' - }, - { - Conditions: [ - { - Condition: 'Equals', - IsRequired: false, - Property: 'IsSecondaryAudio', - Value: 'false' - } - ], - Type: 'VideoAudio' - }, - { - Codec: 'h264', - Conditions: [ - { - Condition: 'NotEquals', - IsRequired: false, - Property: 'IsAnamorphic', - Value: 'true' - }, - { - Condition: 'EqualsAny', - IsRequired: false, - Property: 'VideoProfile', - Value: 'high|main|baseline|constrained baseline' - }, - { - Condition: 'EqualsAny', - IsRequired: false, - Property: 'VideoRangeType', - Value: 'SDR' - }, - { - Condition: 'LessThanEqual', - IsRequired: false, - Property: 'VideoLevel', - Value: '52' - }, - { - Condition: 'NotEquals', - IsRequired: false, - Property: 'IsInterlaced', - Value: 'true' - } - ], - Type: 'Video' - }, - { - Codec: 'hevc', - Conditions: [ - { - Condition: 'NotEquals', - IsRequired: false, - Property: 'IsAnamorphic', - Value: 'true' - }, - { - Condition: 'EqualsAny', - IsRequired: false, - Property: 'VideoProfile', - Value: 'main' - }, - { - Condition: 'EqualsAny', - IsRequired: false, - Property: 'VideoRangeType', - Value: 'SDR' - }, - { - Condition: 'LessThanEqual', - IsRequired: false, - Property: 'VideoLevel', - Value: '120' - }, - { - Condition: 'NotEquals', - IsRequired: false, - Property: 'IsInterlaced', - Value: 'true' - } - ], - Type: 'Video' - }, - { - Codec: 'vp9', - Conditions: [ - { - Condition: 'EqualsAny', - IsRequired: false, - Property: 'VideoRangeType', - Value: 'SDR|HDR10|HLG' - } - ], - Type: 'Video' - }, - { - Codec: 'av1', - Conditions: [ - { - Condition: 'EqualsAny', - IsRequired: false, - Property: 'VideoRangeType', - Value: 'SDR|HDR10|HLG' - } - ], - Type: 'Video' - } - ], - ContainerProfiles: [], - DirectPlayProfiles: [ - { - AudioCodec: 'vorbis,opus', - Container: 'webm', - Type: 'Video', - VideoCodec: 'vp8,vp9,av1' - }, - { - AudioCodec: 'aac,mp3,opus,flac,alac,vorbis', - Container: 'mp4,m4v', - Type: 'Video', - VideoCodec: 'h264,vp9,av1' - }, - { - Container: 'opus', - Type: 'Audio' - }, - { - AudioCodec: 'opus', - Container: 'webm', - Type: 'Audio' - }, - { - Container: 'mp3', - Type: 'Audio' - }, - { - Container: 'aac', - Type: 'Audio' - }, - { - AudioCodec: 'aac', - Container: 'm4a', - Type: 'Audio' - }, - { - AudioCodec: 'aac', - Container: 'm4b', - Type: 'Audio' - }, - { - Container: 'flac', - Type: 'Audio' - }, - { - Container: 'alac', - Type: 'Audio' - }, - { - AudioCodec: 'alac', - Container: 'm4a', - Type: 'Audio' - }, - { - AudioCodec: 'alac', - Container: 'm4b', - Type: 'Audio' - }, - { - Container: 'webma', - Type: 'Audio' - }, - { - AudioCodec: 'webma', - Container: 'webm', - Type: 'Audio' - }, - { - Container: 'wav', - Type: 'Audio' - }, - { - Container: 'ogg', - Type: 'Audio' - } - ], - MaxStaticBitrate: 100000000, - MaxStreamingBitrate: 120000000, - MusicStreamingTranscodingBitrate: 384000, - ResponseProfiles: [ - { - Container: 'm4v', - MimeType: 'video/mp4', - Type: 'Video' - } - ], - SubtitleProfiles: [ - { - Format: 'vtt', - Method: 'External' - }, - { - Format: 'ass', - Method: 'External' - }, - { - Format: 'ssa', - Method: 'External' - } - ], - TranscodingProfiles: [ - { - AudioCodec: 'aac', - BreakOnNonKeyFrames: true, - Container: 'ts', - Context: 'Streaming', - MaxAudioChannels: '2', - Protocol: 'hls', - Type: 'Audio' - }, - { - AudioCodec: 'aac', - Container: 'aac', - Context: 'Streaming', - MaxAudioChannels: '2', - Protocol: 'http', - Type: 'Audio' - }, - { - AudioCodec: 'mp3', - Container: 'mp3', - Context: 'Streaming', - MaxAudioChannels: '2', - Protocol: 'http', - Type: 'Audio' - }, - { - AudioCodec: 'opus', - Container: 'opus', - Context: 'Streaming', - MaxAudioChannels: '2', - Protocol: 'http', - Type: 'Audio' - }, - { - AudioCodec: 'wav', - Container: 'wav', - Context: 'Streaming', - MaxAudioChannels: '2', - Protocol: 'http', - Type: 'Audio' - }, - { - AudioCodec: 'opus', - Container: 'opus', - Context: 'Static', - MaxAudioChannels: '2', - Protocol: 'http', - Type: 'Audio' - }, - { - AudioCodec: 'mp3', - Container: 'mp3', - Context: 'Static', - MaxAudioChannels: '2', - Protocol: 'http', - Type: 'Audio' - }, - { - AudioCodec: 'aac', - Container: 'aac', - Context: 'Static', - MaxAudioChannels: '2', - Protocol: 'http', - Type: 'Audio' - }, - { - AudioCodec: 'wav', - Container: 'wav', - Context: 'Static', - MaxAudioChannels: '2', - Protocol: 'http', - Type: 'Audio' - }, - { - AudioCodec: 'aac,mp3', - BreakOnNonKeyFrames: true, - Container: 'ts', - Context: 'Streaming', - MaxAudioChannels: '2', - Protocol: 'hls', - Type: 'Video', - VideoCodec: 'h264' - } - ] - } + DeviceProfile: playbackProfile } - }).then((r) => r.data?.MediaSources?.[0]?.TranscodingUrl); + }).then((r) => ({ + playbackUrl: r.data?.MediaSources?.[0]?.TranscodingUrl, + mediaSourceId: r.data?.MediaSources?.[0]?.Id, + playSessionId: r.data?.PlaySessionId + })); + +export const reportJellyfinPlaybackStarted = ( + itemId: string, + sessionId: string, + mediaSourceId: string, + audioStreamIndex?: number, + subtitleStreamIndex?: number +) => + JellyfinApi.post('/Sessions/Playing', { + body: { + CanSeek: true, + ItemId: itemId, + PlaySessionId: sessionId, + MediaSourceId: mediaSourceId, + AudioStreamIndex: 1, + SubtitleStreamIndex: -1 + } + }); + +export const reportJellyfinPlaybackProgress = ( + itemId: string, + sessionId: string, + isPaused: boolean, + positionTicks: number +) => + JellyfinApi.post('/Sessions/Playing/Progress', { + body: { + ItemId: itemId, + PlaySessionId: sessionId, + IsPaused: isPaused, + PositionTicks: Math.round(positionTicks), + CanSeek: true, + MediaSourceId: itemId + } + }); + +export const reportJellyfinPlaybackStopped = ( + itemId: string, + sessionId: string, + positionTicks: number +) => + JellyfinApi.post('/Sessions/Playing/Stopped', { + body: { + ItemId: itemId, + PlaySessionId: sessionId, + PositionTicks: Math.round(positionTicks), + MediaSourceId: itemId + } + }); diff --git a/src/lib/jellyfin/playback-profiles/directplay-profile.ts b/src/lib/jellyfin/playback-profiles/directplay-profile.ts new file mode 100644 index 0000000..31ffc05 --- /dev/null +++ b/src/lib/jellyfin/playback-profiles/directplay-profile.ts @@ -0,0 +1,104 @@ +/** + * @deprecated - Check @/utils/playback-profiles/index + */ + +import { DlnaProfileType } from '@jellyfin/sdk/lib/generated-client'; +import type { DirectPlayProfile } from '@jellyfin/sdk/lib/generated-client'; +import { getSupportedMP4VideoCodecs } from './helpers/mp4-video-formats'; +import { getSupportedMP4AudioCodecs } from './helpers/mp4-audio-formats'; +import { hasMkvSupport } from './helpers/transcoding-formats'; +import { getSupportedWebMAudioCodecs } from './helpers/webm-audio-formats'; +import { getSupportedWebMVideoCodecs } from './helpers/webm-video-formats'; +import { getSupportedAudioCodecs } from './helpers/audio-formats'; + +/** + * Returns a valid DirectPlayProfile for the current platform. + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns An array of direct play profiles for the current platform. + */ +export function getDirectPlayProfiles( + videoTestElement: HTMLVideoElement +): Array { + const DirectPlayProfiles: DirectPlayProfile[] = []; + + const webmVideoCodecs = getSupportedWebMVideoCodecs(videoTestElement); + const webmAudioCodecs = getSupportedWebMAudioCodecs(videoTestElement); + + const mp4VideoCodecs = getSupportedMP4VideoCodecs(videoTestElement); + const mp4AudioCodecs = getSupportedMP4AudioCodecs(videoTestElement); + + if (webmVideoCodecs.length > 0) { + DirectPlayProfiles.push({ + Container: 'webm', + Type: DlnaProfileType.Video, + VideoCodec: webmVideoCodecs.join(','), + AudioCodec: webmAudioCodecs.join(',') + }); + } + + if (mp4VideoCodecs.length > 0) { + DirectPlayProfiles.push({ + Container: 'mp4,m4v', + Type: DlnaProfileType.Video, + VideoCodec: mp4VideoCodecs.join(','), + AudioCodec: mp4AudioCodecs.join(',') + }); + } + + if (hasMkvSupport(videoTestElement) && mp4VideoCodecs.length > 0) { + DirectPlayProfiles.push({ + Container: 'mkv', + Type: DlnaProfileType.Video, + VideoCodec: mp4VideoCodecs.join(','), + AudioCodec: mp4AudioCodecs.join(',') + }); + } + + const supportedAudio = [ + 'opus', + 'mp3', + 'mp2', + 'aac', + 'flac', + 'alac', + 'webma', + 'wma', + 'wav', + 'ogg', + 'oga' + ]; + + for (const audioFormat of supportedAudio.filter((format) => getSupportedAudioCodecs(format))) { + DirectPlayProfiles.push({ + Container: audioFormat, + Type: DlnaProfileType.Audio + }); + + if (audioFormat === 'opus' || audioFormat === 'webma') { + DirectPlayProfiles.push({ + Container: 'webm', + Type: DlnaProfileType.Audio, + AudioCodec: audioFormat + }); + } + + // aac also appears in the m4a and m4b container + if (audioFormat === 'aac' || audioFormat === 'alac') { + DirectPlayProfiles.push( + { + Container: 'm4a', + AudioCodec: audioFormat, + Type: DlnaProfileType.Audio + }, + { + Container: 'm4b', + AudioCodec: audioFormat, + Type: DlnaProfileType.Audio + } + ); + } + } + + return DirectPlayProfiles; +} diff --git a/src/lib/jellyfin/playback-profiles/helpers/audio-formats.ts b/src/lib/jellyfin/playback-profiles/helpers/audio-formats.ts new file mode 100644 index 0000000..ed46157 --- /dev/null +++ b/src/lib/jellyfin/playback-profiles/helpers/audio-formats.ts @@ -0,0 +1,38 @@ +/** + * @deprecated - Check @/utils/playback-profiles/index + */ + +import { isApple, isTizen, isTv, isWebOS } from '$lib/utils/browser-detection'; + +/** + * Determines if audio codec is supported + */ +export function getSupportedAudioCodecs(format: string): boolean { + let typeString; + + if (format === 'flac' && isTv()) { + return true; + } else if (format === 'wma' && isTizen()) { + return true; + } else if (format === 'asf' && isTv()) { + return true; + } else if (format === 'opus') { + if (!isWebOS()) { + typeString = 'audio/ogg; codecs="opus"'; + + return !!document.createElement('audio').canPlayType(typeString).replace(/no/, ''); + } + + return false; + } else if (format === 'alac' && isApple()) { + return true; + } else if (format === 'webma') { + typeString = 'audio/webm'; + } else if (format === 'mp2') { + typeString = 'audio/mpeg'; + } else { + typeString = 'audio/' + format; + } + + return !!document.createElement('audio').canPlayType(typeString).replace(/no/, ''); +} diff --git a/src/lib/jellyfin/playback-profiles/helpers/codec-profiles.ts b/src/lib/jellyfin/playback-profiles/helpers/codec-profiles.ts new file mode 100644 index 0000000..edfa17e --- /dev/null +++ b/src/lib/jellyfin/playback-profiles/helpers/codec-profiles.ts @@ -0,0 +1,329 @@ +/** + * @deprecated - Check @/utils/playback-profiles/index + */ + +import { + CodecType, + ProfileConditionType, + ProfileConditionValue +} from '@jellyfin/sdk/lib/generated-client'; +import type { ProfileCondition, CodecProfile } from '@jellyfin/sdk/lib/generated-client'; +import { + isApple, + isChromiumBased, + isEdge, + isMobile, + isPs4, + isTizen, + isTv, + isWebOS, + isXbox, + safariVersion +} from '$lib/utils/browser-detection'; + +/** + * Gets the max video bitrate + * + * @returns Returns the MaxVideoBitrate + */ +function getGlobalMaxVideoBitrate(): number | undefined { + let isTizenFhd = false; + + if ( + isTizen() && + 'webapis' in window && + typeof window.webapis === 'object' && + window.webapis && + 'productinfo' in window.webapis && + typeof window.webapis.productinfo === 'object' && + window.webapis.productinfo && + 'isUdPanelSupported' in window.webapis.productinfo && + typeof window.webapis.productinfo.isUdPanelSupported === 'function' + ) { + isTizenFhd = !window.webapis.productinfo.isUdPanelSupported(); + } + + // TODO: These values are taken directly from Jellyfin-web. + // The source of them needs to be investigated. + if (isPs4()) { + return 8_000_000; + } + + if (isXbox()) { + return 12_000_000; + } + + if (isTizen() && isTizenFhd) { + return 20_000_000; + } +} + +/** + * Creates a profile condition object for use in device playback profiles. + * + * @param Property - Value for the property + * @param Condition - Condition that the property must comply with + * @param Value - Value to check in the condition + * @param IsRequired - Whether this property is required + * @returns - Constructed ProfileCondition object + */ +function createProfileCondition( + Property: ProfileConditionValue, + Condition: ProfileConditionType, + Value: string, + IsRequired = false +): ProfileCondition { + return { + Condition, + Property, + Value, + IsRequired + }; +} + +/** + * Gets the AAC audio codec profile conditions + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns - Array of ACC Profile conditions + */ +export function getAacCodecProfileConditions( + videoTestElement: HTMLVideoElement +): ProfileCondition[] { + const supportsSecondaryAudio = isTizen(); + + const conditions: ProfileCondition[] = []; + + // Handle he-aac not supported + if ( + !videoTestElement.canPlayType('video/mp4; codecs="avc1.640029, mp4a.40.5"').replace(/no/, '') + ) { + // TODO: This needs to become part of the stream url in order to prevent stream copy + conditions.push( + createProfileCondition( + ProfileConditionValue.AudioProfile, + ProfileConditionType.NotEquals, + 'HE-AAC' + ) + ); + } + + if (!supportsSecondaryAudio) { + conditions.push( + createProfileCondition( + ProfileConditionValue.IsSecondaryAudio, + ProfileConditionType.Equals, + 'false' + ) + ); + } + + return conditions; +} + +/** + * Gets an array with all the codec profiles that this client supports + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns - Array containing the different profiles for the client + */ +export function getCodecProfiles(videoTestElement: HTMLVideoElement): CodecProfile[] { + const CodecProfiles: CodecProfile[] = []; + + const aacProfileConditions = getAacCodecProfileConditions(videoTestElement); + + const supportsSecondaryAudio = isTizen(); + + if (aacProfileConditions.length > 0) { + CodecProfiles.push({ + Type: CodecType.VideoAudio, + Codec: 'aac', + Conditions: aacProfileConditions + }); + } + + if (!supportsSecondaryAudio) { + CodecProfiles.push({ + Type: CodecType.VideoAudio, + Conditions: [ + createProfileCondition( + ProfileConditionValue.IsSecondaryAudio, + ProfileConditionType.Equals, + 'false' + ) + ] + }); + } + + let maxH264Level = 42; + let h264Profiles = 'high|main|baseline|constrained baseline'; + + if (isTv() || videoTestElement.canPlayType('video/mp4; codecs="avc1.640833"').replace(/no/, '')) { + maxH264Level = 51; + } + + if (videoTestElement.canPlayType('video/mp4; codecs="avc1.640834"').replace(/no/, '')) { + maxH264Level = 52; + } + + if ( + (isTizen() || + videoTestElement.canPlayType('video/mp4; codecs="avc1.6e0033"').replace(/no/, '')) && // TODO: These tests are passing in Safari, but playback is failing + (!isApple() || !isWebOS() || !(isEdge() && !isChromiumBased())) + ) { + h264Profiles += '|high 10'; + } + + let maxHevcLevel = 120; + let hevcProfiles = 'main'; + const hevcProfilesMain10 = 'main|main 10'; + + // HEVC Main profile, Level 4.1 + if ( + videoTestElement.canPlayType('video/mp4; codecs="hvc1.1.4.L123"').replace(/no/, '') || + videoTestElement.canPlayType('video/mp4; codecs="hev1.1.4.L123"').replace(/no/, '') + ) { + maxHevcLevel = 123; + } + + // HEVC Main10 profile, Level 4.1 + if ( + videoTestElement.canPlayType('video/mp4; codecs="hvc1.2.4.L123"').replace(/no/, '') || + videoTestElement.canPlayType('video/mp4; codecs="hev1.2.4.L123"').replace(/no/, '') + ) { + maxHevcLevel = 123; + hevcProfiles = hevcProfilesMain10; + } + + // HEVC Main10 profile, Level 5.1 + if ( + videoTestElement.canPlayType('video/mp4; codecs="hvc1.2.4.L153"').replace(/no/, '') || + videoTestElement.canPlayType('video/mp4; codecs="hev1.2.4.L153"').replace(/no/, '') + ) { + maxHevcLevel = 153; + hevcProfiles = hevcProfilesMain10; + } + + // HEVC Main10 profile, Level 6.1 + if ( + videoTestElement.canPlayType('video/mp4; codecs="hvc1.2.4.L183"').replace(/no/, '') || + videoTestElement.canPlayType('video/mp4; codecs="hev1.2.4.L183"').replace(/no/, '') + ) { + maxHevcLevel = 183; + hevcProfiles = hevcProfilesMain10; + } + + const hevcCodecProfileConditions: ProfileCondition[] = [ + createProfileCondition( + ProfileConditionValue.IsAnamorphic, + ProfileConditionType.NotEquals, + 'true' + ), + createProfileCondition( + ProfileConditionValue.VideoProfile, + ProfileConditionType.EqualsAny, + hevcProfiles + ), + createProfileCondition( + ProfileConditionValue.VideoLevel, + ProfileConditionType.LessThanEqual, + maxHevcLevel.toString() + ) + ]; + + const h264CodecProfileConditions: ProfileCondition[] = [ + createProfileCondition( + ProfileConditionValue.IsAnamorphic, + ProfileConditionType.NotEquals, + 'true' + ), + createProfileCondition( + ProfileConditionValue.VideoProfile, + ProfileConditionType.EqualsAny, + h264Profiles + ), + createProfileCondition( + ProfileConditionValue.VideoLevel, + ProfileConditionType.LessThanEqual, + maxH264Level.toString() + ) + ]; + + if (!isTv()) { + h264CodecProfileConditions.push( + createProfileCondition( + ProfileConditionValue.IsInterlaced, + ProfileConditionType.NotEquals, + 'true' + ) + ); + hevcCodecProfileConditions.push( + createProfileCondition( + ProfileConditionValue.IsInterlaced, + ProfileConditionType.NotEquals, + 'true' + ) + ); + } + + const globalMaxVideoBitrate = (getGlobalMaxVideoBitrate() || '').toString(); + + if (globalMaxVideoBitrate) { + h264CodecProfileConditions.push( + createProfileCondition( + ProfileConditionValue.VideoBitrate, + ProfileConditionType.LessThanEqual, + globalMaxVideoBitrate, + true + ) + ); + } + + if (globalMaxVideoBitrate) { + hevcCodecProfileConditions.push( + createProfileCondition( + ProfileConditionValue.VideoBitrate, + ProfileConditionType.LessThanEqual, + globalMaxVideoBitrate, + true + ) + ); + } + + // On iOS 12.x, for TS container max h264 level is 4.2 + if (isApple() && isMobile() && Number(safariVersion()) < 13) { + const codecProfile = { + Type: CodecType.Video, + Codec: 'h264', + Container: 'ts', + Conditions: h264CodecProfileConditions.filter((condition) => { + return condition.Property !== 'VideoLevel'; + }) + }; + + codecProfile.Conditions.push( + createProfileCondition( + ProfileConditionValue.VideoLevel, + ProfileConditionType.LessThanEqual, + '42' + ) + ); + + CodecProfiles.push(codecProfile); + } + + CodecProfiles.push( + { + Type: CodecType.Video, + Codec: 'h264', + Conditions: h264CodecProfileConditions + }, + { + Type: CodecType.Video, + Codec: 'hevc', + Conditions: hevcCodecProfileConditions + } + ); + + return CodecProfiles; +} diff --git a/src/lib/jellyfin/playback-profiles/helpers/fmp4-audio-formats.ts b/src/lib/jellyfin/playback-profiles/helpers/fmp4-audio-formats.ts new file mode 100644 index 0000000..8031a31 --- /dev/null +++ b/src/lib/jellyfin/playback-profiles/helpers/fmp4-audio-formats.ts @@ -0,0 +1,49 @@ +/** + * @deprecated - Check @/utils/playback-profiles/index + */ + +import { getSupportedAudioCodecs } from './audio-formats'; +import { + hasAacSupport, + hasAc3InHlsSupport, + hasAc3Support, + hasEac3Support, + hasMp3AudioSupport +} from './mp4-audio-formats'; +import { isEdge } from '$lib/utils/browser-detection'; + +/** + * Gets an array with the supported fmp4 codecs + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns List of supported FMP4 audio codecs + */ +export function getSupportedFmp4AudioCodecs(videoTestElement: HTMLVideoElement): string[] { + const codecs = []; + + if (hasAacSupport(videoTestElement)) { + codecs.push('aac'); + } + + if (hasMp3AudioSupport(videoTestElement)) { + codecs.push('mp3'); + } + + if (hasAc3Support(videoTestElement) && hasAc3InHlsSupport(videoTestElement)) { + codecs.push('ac3'); + + if (hasEac3Support(videoTestElement)) { + codecs.push('eac3'); + } + } + + if (getSupportedAudioCodecs('flac') && !isEdge()) { + codecs.push('flac'); + } + + if (getSupportedAudioCodecs('alac')) { + codecs.push('alac'); + } + + return codecs; +} diff --git a/src/lib/jellyfin/playback-profiles/helpers/fmp4-video-formats.ts b/src/lib/jellyfin/playback-profiles/helpers/fmp4-video-formats.ts new file mode 100644 index 0000000..0467552 --- /dev/null +++ b/src/lib/jellyfin/playback-profiles/helpers/fmp4-video-formats.ts @@ -0,0 +1,36 @@ +/** + * @deprecated - Check @/utils/playback-profiles/index + */ + +import { hasH264Support, hasHevcSupport } from './mp4-video-formats'; +import { + isApple, + isChrome, + isEdge, + isFirefox, + isTizen, + isWebOS +} from '$lib/utils/browser-detection'; + +/** + * Gets an array of supported fmp4 video codecs + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns List of supported fmp4 video codecs + */ +export function getSupportedFmp4VideoCodecs(videoTestElement: HTMLVideoElement): string[] { + const codecs = []; + + if ((isApple() || isEdge() || isTizen() || isWebOS()) && hasHevcSupport(videoTestElement)) { + codecs.push('hevc'); + } + + if ( + hasH264Support(videoTestElement) && + (isChrome() || isFirefox() || isApple() || isEdge() || isTizen() || isWebOS()) + ) { + codecs.push('h264'); + } + + return codecs; +} diff --git a/src/lib/jellyfin/playback-profiles/helpers/hls-formats.ts b/src/lib/jellyfin/playback-profiles/helpers/hls-formats.ts new file mode 100644 index 0000000..b649917 --- /dev/null +++ b/src/lib/jellyfin/playback-profiles/helpers/hls-formats.ts @@ -0,0 +1,81 @@ +/** + * @deprecated - Check @/utils/playback-profiles/index + */ + +import { hasH264Support, hasH265Support } from './mp4-video-formats'; +import { hasEac3Support, hasAacSupport } from './mp4-audio-formats'; +import { getSupportedAudioCodecs } from './audio-formats'; +import { isTv } from '$lib/utils/browser-detection'; + +/** + * Check if client supports AC3 in HLS stream + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Determines if the browser has AC3 in HLS support + */ +function supportsAc3InHls(videoTestElement: HTMLVideoElement): boolean | string { + if (isTv()) { + return true; + } + + if (videoTestElement.canPlayType) { + return ( + videoTestElement + .canPlayType('application/x-mpegurl; codecs="avc1.42E01E, ac-3"') + .replace(/no/, '') || + videoTestElement + .canPlayType('application/vnd.apple.mpegURL; codecs="avc1.42E01E, ac-3"') + .replace(/no/, '') + ); + } + + return false; +} + +/** + * Gets the supported HLS video codecs + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Array of video codecs supported in HLS + */ +export function getHlsVideoCodecs(videoTestElement: HTMLVideoElement): string[] { + const hlsVideoCodecs = []; + + if (hasH264Support(videoTestElement)) { + hlsVideoCodecs.push('h264'); + } + + if (hasH265Support(videoTestElement) || isTv()) { + hlsVideoCodecs.push('h265', 'hevc'); + } + + return hlsVideoCodecs; +} + +/** + * Gets the supported HLS audio codecs + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Array of audio codecs supported in HLS + */ +export function getHlsAudioCodecs(videoTestElement: HTMLVideoElement): string[] { + const hlsVideoAudioCodecs = []; + + if (supportsAc3InHls(videoTestElement)) { + hlsVideoAudioCodecs.push('ac3'); + + if (hasEac3Support(videoTestElement)) { + hlsVideoAudioCodecs.push('eac3'); + } + } + + if (hasAacSupport(videoTestElement)) { + hlsVideoAudioCodecs.push('aac'); + } + + if (getSupportedAudioCodecs('opus')) { + hlsVideoAudioCodecs.push('opus'); + } + + return hlsVideoAudioCodecs; +} diff --git a/src/lib/jellyfin/playback-profiles/helpers/mp4-audio-formats.ts b/src/lib/jellyfin/playback-profiles/helpers/mp4-audio-formats.ts new file mode 100644 index 0000000..80c59ae --- /dev/null +++ b/src/lib/jellyfin/playback-profiles/helpers/mp4-audio-formats.ts @@ -0,0 +1,180 @@ +/** + * @deprecated - Check @/utils/playback-profiles/index + */ + +import { hasVp8Support } from './mp4-video-formats'; +import { getSupportedAudioCodecs } from './audio-formats'; +import { + isTizen, + isTizen4, + isTizen5, + isTizen55, + isTv, + isWebOS +} from '$lib/utils/browser-detection'; + +/** + * Checks if the client can play the AC3 codec + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Determines if the browser has AC3 support + */ +export function hasAc3Support(videoTestElement: HTMLVideoElement): boolean { + if (isTv()) { + return true; + } + + return !!videoTestElement.canPlayType('audio/mp4; codecs="ac-3"').replace(/no/, ''); +} + +/** + * Checks if the client can play AC3 in a HLS stream + * @param videoTestElement - A HTML video element for testing codecs + * @returns Determines if the browser has AC3 support + */ +export function hasAc3InHlsSupport(videoTestElement: HTMLVideoElement): boolean { + if (isTizen() || isWebOS()) { + return true; + } + + if (videoTestElement.canPlayType) { + return !!( + videoTestElement + .canPlayType('application/x-mpegurl; codecs="avc1.42E01E, ac-3"') + .replace(/no/, '') || + videoTestElement + .canPlayType('application/vnd.apple.mpegURL; codecs="avc1.42E01E, ac-3"') + .replace(/no/, '') + ); + } + + return false; +} + +/** + * Checks if the cliemt has E-AC3 codec support + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Determines if browser has EAC3 support + */ +export function hasEac3Support(videoTestElement: HTMLVideoElement): boolean { + if (isTv()) { + return true; + } + + return !!videoTestElement.canPlayType('audio/mp4; codecs="ec-3"').replace(/no/, ''); +} + +/** + * Checks if the client has AAC codec support + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Determines if browser has AAC support + */ +export function hasAacSupport(videoTestElement: HTMLVideoElement): boolean { + return !!videoTestElement + .canPlayType('video/mp4; codecs="avc1.640029, mp4a.40.2"') + .replace(/no/, ''); +} + +/** + * Checks if the client has MP2 codec support + * + * @returns Determines if browser has MP2 support + */ +export function hasMp2AudioSupport(): boolean { + return isTv(); +} + +/** + * Checks if the client has MP3 audio codec support + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Determines if browser has Mp3 support + */ +export function hasMp3AudioSupport(videoTestElement: HTMLVideoElement): boolean { + return !!( + videoTestElement.canPlayType('video/mp4; codecs="avc1.640029, mp4a.69"').replace(/no/, '') || + videoTestElement.canPlayType('video/mp4; codecs="avc1.640029, mp4a.6B"').replace(/no/, '') || + videoTestElement.canPlayType('video/mp4; codecs="avc1.640029, mp3"').replace(/no/, '') + ); +} + +/** + * Determines DTS audio support + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Determines if browserr has DTS audio support + */ +export function hasDtsSupport(videoTestElement: HTMLVideoElement): boolean | string { + // DTS audio not supported in 2018 models (Tizen 4.0) + if (isTizen4() || isTizen5() || isTizen55()) { + return false; + } + + return ( + isTv() || + videoTestElement.canPlayType('video/mp4; codecs="dts-"').replace(/no/, '') || + videoTestElement.canPlayType('video/mp4; codecs="dts+"').replace(/no/, '') + ); +} + +/** + * Gets an array of supported MP4 codecs + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Array of supported MP4 audio codecs + */ +export function getSupportedMP4AudioCodecs(videoTestElement: HTMLVideoElement): string[] { + const codecs = []; + + if (hasAacSupport(videoTestElement)) { + codecs.push('aac'); + } + + if (hasMp3AudioSupport(videoTestElement)) { + codecs.push('mp3'); + } + + if (hasAc3Support(videoTestElement)) { + codecs.push('ac3'); + + if (hasEac3Support(videoTestElement)) { + codecs.push('eac3'); + } + } + + if (hasMp2AudioSupport()) { + codecs.push('mp2'); + } + + if (hasDtsSupport(videoTestElement)) { + codecs.push('dca', 'dts'); + } + + if (isTizen() || isWebOS()) { + codecs.push('pcm_s16le', 'pcm_s24le'); + } + + if (isTizen()) { + codecs.push('aac_latm'); + } + + if (getSupportedAudioCodecs('opus')) { + codecs.push('opus'); + } + + if (getSupportedAudioCodecs('flac')) { + codecs.push('flac'); + } + + if (getSupportedAudioCodecs('alac')) { + codecs.push('alac'); + } + + if (hasVp8Support(videoTestElement) || isTizen()) { + codecs.push('vorbis'); + } + + return codecs; +} diff --git a/src/lib/jellyfin/playback-profiles/helpers/mp4-video-formats.ts b/src/lib/jellyfin/playback-profiles/helpers/mp4-video-formats.ts new file mode 100644 index 0000000..ff744cf --- /dev/null +++ b/src/lib/jellyfin/playback-profiles/helpers/mp4-video-formats.ts @@ -0,0 +1,158 @@ +/** + * @deprecated - Check @/utils/playback-profiles/index + */ + +import { isApple, isTizen, isTizen55, isTv, isWebOS5 } from '$lib/utils/browser-detection'; + +/** + * Checks if the client has support for the H264 codec + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Determines if browser has H264 support + */ +export function hasH264Support(videoTestElement: HTMLVideoElement): boolean { + return !!( + videoTestElement.canPlayType && + videoTestElement.canPlayType('video/mp4; codecs="avc1.42E01E, mp4a.40.2"').replace(/no/, '') + ); +} + +/** + * Checks if the client has support for the H265 codec + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Determines if browser has H265 support + */ +export function hasH265Support(videoTestElement: HTMLVideoElement): boolean { + if (isTv()) { + return true; + } + + return !!( + videoTestElement.canPlayType && + (videoTestElement.canPlayType('video/mp4; codecs="hvc1.1.L120"').replace(/no/, '') || + videoTestElement.canPlayType('video/mp4; codecs="hev1.1.L120"').replace(/no/, '') || + videoTestElement.canPlayType('video/mp4; codecs="hvc1.1.0.L120"').replace(/no/, '') || + videoTestElement.canPlayType('video/mp4; codecs="hev1.1.0.L120"').replace(/no/, '')) + ); +} + +/** + * Checks if the client has support for the HEVC codec + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Determines if browser has HEVC Support + */ +export function hasHevcSupport(videoTestElement: HTMLVideoElement): boolean { + if (isTv()) { + return true; + } + + return !!( + !!videoTestElement.canPlayType && + (videoTestElement.canPlayType('video/mp4; codecs="hvc1.1.L120"').replace(/no/, '') || + videoTestElement.canPlayType('video/mp4; codecs="hev1.1.L120"').replace(/no/, '') || + videoTestElement.canPlayType('video/mp4; codecs="hvc1.1.0.L120"').replace(/no/, '') || + videoTestElement.canPlayType('video/mp4; codecs="hev1.1.0.L120"').replace(/no/, '')) + ); +} + +/** + * Checks if the client has support for the AV1 codec + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Determines if browser has AV1 support + */ +export function hasAv1Support(videoTestElement: HTMLVideoElement): boolean { + if ((isTizen() && isTizen55()) || (isWebOS5() && window.outerHeight >= 2160)) { + return true; + } + + return !!( + videoTestElement.canPlayType && + videoTestElement.canPlayType('video/webm; codecs="av01.0.15M.10"').replace(/no/, '') + ); +} + +/** + * Check if the client has support for the VC1 codec + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Determines if browser has VC1 support + */ +function hasVc1Support(videoTestElement: HTMLVideoElement): boolean { + return !!(isTv() || videoTestElement.canPlayType('video/mp4; codecs="vc-1"').replace(/no/, '')); +} + +/** + * Checks if the client has support for the VP8 codec + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Determines if browser has VP8 support + */ +export function hasVp8Support(videoTestElement: HTMLVideoElement): boolean { + return !!( + videoTestElement.canPlayType && + videoTestElement.canPlayType('video/webm; codecs="vp8"').replace(/no/, '') + ); +} + +/** + * Checks if the client has support for the VP9 codec + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Determines if browser has VP9 support + */ +export function hasVp9Support(videoTestElement: HTMLVideoElement): boolean { + return !!( + videoTestElement.canPlayType && + videoTestElement.canPlayType('video/webm; codecs="vp9"').replace(/no/, '') + ); +} + +/** + * Queries the platform for the codecs suppers in an MP4 container. + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Array of codec identifiers. + */ +export function getSupportedMP4VideoCodecs(videoTestElement: HTMLVideoElement): string[] { + const codecs = []; + + if (hasH264Support(videoTestElement)) { + codecs.push('h264'); + } + + if ( + hasHevcSupport(videoTestElement) && // Safari is lying on HDR and 60fps videos, use fMP4 instead + !isApple() + ) { + codecs.push('hevc'); + } + + if (isTv()) { + codecs.push('mpeg2video'); + } + + if (hasVc1Support(videoTestElement)) { + codecs.push('vc1'); + } + + if (isTizen()) { + codecs.push('msmpeg4v2'); + } + + if (hasVp8Support(videoTestElement)) { + codecs.push('vp8'); + } + + if (hasVp9Support(videoTestElement)) { + codecs.push('vp9'); + } + + if (hasAv1Support(videoTestElement)) { + codecs.push('av1'); + } + + return codecs; +} diff --git a/src/lib/jellyfin/playback-profiles/helpers/transcoding-formats.ts b/src/lib/jellyfin/playback-profiles/helpers/transcoding-formats.ts new file mode 100644 index 0000000..dee6504 --- /dev/null +++ b/src/lib/jellyfin/playback-profiles/helpers/transcoding-formats.ts @@ -0,0 +1,47 @@ +/** + * @deprecated - Check @/utils/playback-profiles/index + */ + +import { isEdge, isTizen, isTv, supportsMediaSource } from '$lib/utils/browser-detection'; + +/** + * Checks if the client can play native HLS + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns Determines if the browser can play native Hls + */ +export function canPlayNativeHls(videoTestElement: HTMLVideoElement): boolean { + if (isTizen()) { + return true; + } + + return !!( + videoTestElement.canPlayType('application/x-mpegURL').replace(/no/, '') || + videoTestElement.canPlayType('application/vnd.apple.mpegURL').replace(/no/, '') + ); +} + +/** + * Determines if the browser can play Hls with Media Source Extensions + */ +export function canPlayHlsWithMSE(): boolean { + return supportsMediaSource(); +} + +/** + * Determines if the browser can play Mkvs + */ +export function hasMkvSupport(videoTestElement: HTMLVideoElement): boolean { + if (isTv()) { + return true; + } + + if ( + videoTestElement.canPlayType('video/x-matroska').replace(/no/, '') || + videoTestElement.canPlayType('video/mkv').replace(/no/, '') + ) { + return true; + } + + return !!isEdge(); +} diff --git a/src/lib/jellyfin/playback-profiles/helpers/ts-audio-formats.ts b/src/lib/jellyfin/playback-profiles/helpers/ts-audio-formats.ts new file mode 100644 index 0000000..db4d001 --- /dev/null +++ b/src/lib/jellyfin/playback-profiles/helpers/ts-audio-formats.ts @@ -0,0 +1,38 @@ +/** + * @deprecated - Check @/utils/playback-profiles/index + */ + +import { + hasAacSupport, + hasAc3InHlsSupport, + hasAc3Support, + hasEac3Support, + hasMp3AudioSupport +} from './mp4-audio-formats'; + +/** + * List of supported Ts audio codecs + */ +export function getSupportedTsAudioCodecs( + videoTestElement: HTMLVideoElement +): string[] { + const codecs = []; + + if (hasAacSupport(videoTestElement)) { + codecs.push('aac'); + } + + if (hasMp3AudioSupport(videoTestElement)) { + codecs.push('mp3'); + } + + if (hasAc3Support(videoTestElement) && hasAc3InHlsSupport(videoTestElement)) { + codecs.push('ac3'); + + if (hasEac3Support(videoTestElement)) { + codecs.push('eac3'); + } + } + + return codecs; +} diff --git a/src/lib/jellyfin/playback-profiles/helpers/ts-video-formats.ts b/src/lib/jellyfin/playback-profiles/helpers/ts-video-formats.ts new file mode 100644 index 0000000..08d69be --- /dev/null +++ b/src/lib/jellyfin/playback-profiles/helpers/ts-video-formats.ts @@ -0,0 +1,20 @@ +/** + * @deprecated - Check @/utils/playback-profiles/index + */ + +import { hasH264Support } from './mp4-video-formats'; + +/** + * List of supported ts video codecs + */ +export function getSupportedTsVideoCodecs( + videoTestElement: HTMLVideoElement +): string[] { + const codecs = []; + + if (hasH264Support(videoTestElement)) { + codecs.push('h264'); + } + + return codecs; +} diff --git a/src/lib/jellyfin/playback-profiles/helpers/webm-audio-formats.ts b/src/lib/jellyfin/playback-profiles/helpers/webm-audio-formats.ts new file mode 100644 index 0000000..fa0cb36 --- /dev/null +++ b/src/lib/jellyfin/playback-profiles/helpers/webm-audio-formats.ts @@ -0,0 +1,20 @@ +/** + * @deprecated - Check @/utils/playback-profiles/index + */ + +import { isWebOS } from '$lib/utils/browser-detection'; + +/** + * Get an array of supported codecs + */ +export function getSupportedWebMAudioCodecs(videoTestElement: HTMLVideoElement): string[] { + const codecs = []; + + codecs.push('vorbis'); + + if (!isWebOS() && videoTestElement.canPlayType('audio/ogg; codecs="opus"').replace(/no/, '')) { + codecs.push('opus'); + } + + return codecs; +} diff --git a/src/lib/jellyfin/playback-profiles/helpers/webm-video-formats.ts b/src/lib/jellyfin/playback-profiles/helpers/webm-video-formats.ts new file mode 100644 index 0000000..a791c25 --- /dev/null +++ b/src/lib/jellyfin/playback-profiles/helpers/webm-video-formats.ts @@ -0,0 +1,32 @@ +/** + * @deprecated - Check @/utils/playback-profiles/index + */ + +import { + hasAv1Support, + hasVp8Support, + hasVp9Support +} from './mp4-video-formats'; + +/** + * Get an array of supported codecs WebM video codecs + */ +export function getSupportedWebMVideoCodecs( + videoTestElement: HTMLVideoElement +): string[] { + const codecs = []; + + if (hasVp8Support(videoTestElement)) { + codecs.push('vp8'); + } + + if (hasVp9Support(videoTestElement)) { + codecs.push('vp9'); + } + + if (hasAv1Support(videoTestElement)) { + codecs.push('av1'); + } + + return codecs; +} diff --git a/src/lib/jellyfin/playback-profiles/index.ts b/src/lib/jellyfin/playback-profiles/index.ts new file mode 100644 index 0000000..206c03f --- /dev/null +++ b/src/lib/jellyfin/playback-profiles/index.ts @@ -0,0 +1,58 @@ +/** + * @deprecated + * Since we're targeting modern environments/devices only, it makes sense to switch + * to the native MediaCapabilities API, widely supported on modern devices, but not in older. + * + * Given a media file, we should test with MC the compatibility of video, audio and subtitle streams + * independently: + * If success: Don't request transcoding and direct play that specific stream. + * If failure: Request transcoding of the failing streams to a previously hardcoded + * bitrate/codec combination + * + * For the hardcoded bitrate/codecs combination we can use what we know that are universally + * compatible, even without testing for explicit compatibility (we can do simple checks, + * but the more we do, the complex/less portable our solution can get). + * Examples: H264, AAC and VTT/SASS (thanks to JASSUB). + * + * Other codec combinations can be hardcoded, even if they're not direct-playable in + * most browsers (like H265 or AV1), so the few browsers that support them benefits from less bandwidth + * usage (although this will rarely happen: The most expected situations when transcoding + * is when the media's codecs are more "powerful" than what the client is capable of, and H265 is + * pretty modern, so it would've been catched-up by MediaCapabilities. However, + * we must take into account the playback of really old codecs like MPEG or H263, + * whose support are probably likely going to be removed from browsers, + * so MediaCapabilities reports as unsupported, so we would be going from an "inferior" codec to a + * "superior" codec in this situation) + */ + +import type { DeviceProfile as DP } from '@jellyfin/sdk/lib/generated-client'; +import { getCodecProfiles } from './helpers/codec-profiles'; +import { getDirectPlayProfiles } from './directplay-profile'; +import { getTranscodingProfiles } from './transcoding-profile'; +import { getSubtitleProfiles } from './subtitle-profile'; +import { getResponseProfiles } from './response-profile'; + +export type DeviceProfile = DP; + +/** + * Creates a device profile containing supported codecs for the active Cast device. + * + * @param videoTestElement - Dummy video element for compatibility tests + * @returns Device profile. + */ +function getDeviceProfile(videoTestElement?: HTMLVideoElement): DP { + const element = videoTestElement || document.createElement('video'); + return { + MaxStreamingBitrate: 120_000_000, + MaxStaticBitrate: 0, + MusicStreamingTranscodingBitrate: Math.min(120_000_000, 192_000), + DirectPlayProfiles: getDirectPlayProfiles(element), + TranscodingProfiles: getTranscodingProfiles(element), + ContainerProfiles: [], + CodecProfiles: getCodecProfiles(element), + SubtitleProfiles: getSubtitleProfiles(), + ResponseProfiles: getResponseProfiles() + }; +} + +export default getDeviceProfile; diff --git a/src/lib/jellyfin/playback-profiles/response-profile.ts b/src/lib/jellyfin/playback-profiles/response-profile.ts new file mode 100644 index 0000000..ff87b63 --- /dev/null +++ b/src/lib/jellyfin/playback-profiles/response-profile.ts @@ -0,0 +1,23 @@ +/** + * @deprecated - Check @/utils/playback-profiles/index + */ + +import { DlnaProfileType } from '@jellyfin/sdk/lib/generated-client'; +import type { ResponseProfile } from '@jellyfin/sdk/lib/generated-client'; + +/** + * Returns a valid ResponseProfile for the current platform. + * + * @returns An array of subtitle profiles for the current platform. + */ +export function getResponseProfiles(): Array { + const ResponseProfiles = []; + + ResponseProfiles.push({ + Type: DlnaProfileType.Video, + Container: 'm4v', + MimeType: 'video/mp4' + }); + + return ResponseProfiles; +} diff --git a/src/lib/jellyfin/playback-profiles/subtitle-profile.ts b/src/lib/jellyfin/playback-profiles/subtitle-profile.ts new file mode 100644 index 0000000..5f335cb --- /dev/null +++ b/src/lib/jellyfin/playback-profiles/subtitle-profile.ts @@ -0,0 +1,32 @@ +/** + * @deprecated - Check @/utils/playback-profiles/index + */ + +import { SubtitleDeliveryMethod } from '@jellyfin/sdk/lib/generated-client'; +import type { SubtitleProfile } from '@jellyfin/sdk/lib/generated-client'; + +/** + * Returns a valid SubtitleProfile for the current platform. + * + * @returns An array of subtitle profiles for the current platform. + */ +export function getSubtitleProfiles(): Array { + const SubtitleProfiles: Array = []; + + SubtitleProfiles.push( + { + Format: 'vtt', + Method: SubtitleDeliveryMethod.External + }, + { + Format: 'ass', + Method: SubtitleDeliveryMethod.External + }, + { + Format: 'ssa', + Method: SubtitleDeliveryMethod.External + } + ); + + return SubtitleProfiles; +} diff --git a/src/lib/jellyfin/playback-profiles/transcoding-profile.ts b/src/lib/jellyfin/playback-profiles/transcoding-profile.ts new file mode 100644 index 0000000..4a72aec --- /dev/null +++ b/src/lib/jellyfin/playback-profiles/transcoding-profile.ts @@ -0,0 +1,118 @@ +/** + * @deprecated - Check @/utils/playback-profiles/index + */ + +import { DlnaProfileType, EncodingContext } from '@jellyfin/sdk/lib/generated-client'; +import type { TranscodingProfile } from '@jellyfin/sdk/lib/generated-client'; +import { getSupportedAudioCodecs } from './helpers/audio-formats'; +import { getSupportedMP4AudioCodecs } from './helpers/mp4-audio-formats'; +import { getSupportedMP4VideoCodecs, hasVp8Support } from './helpers/mp4-video-formats'; +import { canPlayNativeHls, canPlayHlsWithMSE, hasMkvSupport } from './helpers/transcoding-formats'; +import { getSupportedTsAudioCodecs } from './helpers/ts-audio-formats'; +import { getSupportedTsVideoCodecs } from './helpers/ts-video-formats'; +import { + isTv, + isApple, + isEdge, + isChromiumBased, + isAndroid, + isTizen +} from '$lib/utils/browser-detection'; + +/** + * Returns a valid TranscodingProfile for the current platform. + * + * @param videoTestElement - A HTML video element for testing codecs + * @returns An array of transcoding profiles for the current platform. + */ +export function getTranscodingProfiles( + videoTestElement: HTMLVideoElement +): Array { + const TranscodingProfiles: TranscodingProfile[] = []; + const physicalAudioChannels = isTv() ? 6 : 2; + + const hlsBreakOnNonKeyFrames = !!( + isApple() || + (isEdge() && !isChromiumBased()) || + !canPlayNativeHls(videoTestElement) + ); + + const mp4AudioCodecs = getSupportedMP4AudioCodecs(videoTestElement); + const mp4VideoCodecs = getSupportedMP4VideoCodecs(videoTestElement); + const canPlayHls = canPlayNativeHls(videoTestElement) || canPlayHlsWithMSE(); + + if (canPlayHls) { + TranscodingProfiles.push({ + // hlsjs, edge, and android all seem to require ts container + Container: + !canPlayNativeHls(videoTestElement) || (isEdge() && !isChromiumBased()) || isAndroid() + ? 'ts' + : 'aac', + Type: DlnaProfileType.Audio, + AudioCodec: 'aac', + Context: EncodingContext.Streaming, + Protocol: 'hls', + MaxAudioChannels: physicalAudioChannels.toString(), + MinSegments: isApple() ? 2 : 1, + BreakOnNonKeyFrames: hlsBreakOnNonKeyFrames + }); + } + + for (const audioFormat of ['aac', 'mp3', 'opus', 'wav'].filter((format) => + getSupportedAudioCodecs(format) + )) { + TranscodingProfiles.push({ + Container: audioFormat, + Type: DlnaProfileType.Audio, + AudioCodec: audioFormat, + Context: EncodingContext.Streaming, + Protocol: 'http', + MaxAudioChannels: physicalAudioChannels.toString() + }); + } + + const hlsInTsVideoCodecs = getSupportedTsVideoCodecs(videoTestElement); + const hlsInTsAudioCodecs = getSupportedTsAudioCodecs(videoTestElement); + + if (canPlayHls && hlsInTsVideoCodecs.length > 0 && hlsInTsAudioCodecs.length > 0) { + TranscodingProfiles.push({ + Container: 'ts', + Type: DlnaProfileType.Video, + AudioCodec: hlsInTsAudioCodecs.join(','), + VideoCodec: hlsInTsVideoCodecs.join(','), + Context: EncodingContext.Streaming, + Protocol: 'hls', + MaxAudioChannels: physicalAudioChannels.toString(), + MinSegments: isApple() ? 2 : 1, + BreakOnNonKeyFrames: hlsBreakOnNonKeyFrames + }); + } + + if (hasMkvSupport(videoTestElement) && !isTizen()) { + TranscodingProfiles.push({ + Container: 'mkv', + Type: DlnaProfileType.Video, + AudioCodec: mp4AudioCodecs.join(','), + VideoCodec: mp4VideoCodecs.join(','), + Context: EncodingContext.Streaming, + MaxAudioChannels: physicalAudioChannels.toString(), + CopyTimestamps: true + }); + } + + if (hasVp8Support(videoTestElement)) { + TranscodingProfiles.push({ + Container: 'webm', + Type: DlnaProfileType.Video, + AudioCodec: 'vorbis', + VideoCodec: 'vpx', + Context: EncodingContext.Streaming, + Protocol: 'http', + // If audio transcoding is needed, limit channels to number of physical audio channels + // Trying to transcode to 5 channels when there are only 2 speakers generally does not sound good + MaxAudioChannels: physicalAudioChannels.toString() + }); + } + + return TranscodingProfiles; +} diff --git a/src/lib/utils/browser-detection.ts b/src/lib/utils/browser-detection.ts new file mode 100644 index 0000000..0fa8d01 --- /dev/null +++ b/src/lib/utils/browser-detection.ts @@ -0,0 +1,304 @@ +/** + * Utilities to detect the browser and get information on the current environment + * Based on https://github.com/google/shaka-player/blob/master/lib/util/platform.js + * + * @deprecated - Parsing User Agent is a maintenance burden and + * should rely on external libraries only. It's also going to be replaced with Client-Hints. + * Migration paths: + * * Check for platform-specific features *where needed* + * directly (i.e Chromecast/AirPlay/MSE) instead of a per-browser basis. + * This will always be 100% fault free. + * + * * Use something like https://www.npmjs.com/package/unique-names-generator to + * distinguish between instances. Instance names could be shown and be modified by the user + * at settings. This would make user instances distinguishable in a 100% fault-tolerant way + * and solve incongruencies like how a device is named. For instance, + * an instance running in an Android Auto headset will be recognised as Android only, which is less + * than ideal. + */ +export function supportsMediaSource(): boolean { + // Browsers that lack a media source implementation will have no reference + // to |window.MediaSource|. + return !!window.MediaSource; +} + +/** + * Check if the user agent of the navigator contains a key. + * + * @private + * @static + * @param key - Key for which to perform a check. + * @returns Determines if user agent of navigator contains a key + */ +function userAgentContains(key: string): boolean { + const userAgent = navigator.userAgent || ''; + + return userAgent.includes(key); +} + +/* Desktop Browsers */ + +/** + * Check if the current platform is Mozilla Firefox. + * + * @returns Determines if browser is Mozilla Firefox + */ +export function isFirefox(): boolean { + return userAgentContains('Firefox/'); +} + +/** + * Check if the current platform is Microsoft Edge. + * + * @static + * @returns Determines if browser is Microsoft Edge + */ +export function isEdge(): boolean { + return userAgentContains('Edg/') || userAgentContains('Edge/'); +} + +/** + * Check if the current platform is Chromium based. + * + * @returns Determines if browser is Chromium based + */ +export function isChromiumBased(): boolean { + return userAgentContains('Chrome'); +} + +/** + * Check if the current platform is Google Chrome. + * + * @returns Determines if browser is Google Chrome + */ +export function isChrome(): boolean { + // The Edge user agent will also contain the "Chrome" keyword, so we need + // to make sure this is not Edge. + return userAgentContains('Chrome') && !isEdge() && !isWebOS(); +} + +/** + * Check if the current platform is from Apple. + * + * Returns true on all iOS browsers and on desktop Safari. + * + * Returns false for non-Safari browsers on macOS, which are independent of + * Apple. + * + * @returns Determines if current platform is from Apple + */ +export function isApple(): boolean { + return navigator?.vendor.includes('Apple') && !isTizen(); +} + +/** + * Returns a major version number for Safari, or Safari-based iOS browsers. + * + * @returns The major version number for Safari + */ +export function safariVersion(): number | undefined { + // All iOS browsers and desktop Safari will return true for isApple(). + if (!isApple()) { + return; + } + + let userAgent = ''; + + if (navigator.userAgent) { + userAgent = navigator.userAgent; + } + + // This works for iOS Safari and desktop Safari, which contain something + // like "Version/13.0" indicating the major Safari or iOS version. + let match = userAgent.match(/Version\/(\d+)/); + + if (match) { + return Number.parseInt(match[1], /* base= */ 10); + } + + // This works for all other browsers on iOS, which contain something like + // "OS 13_3" indicating the major & minor iOS version. + match = userAgent.match(/OS (\d+)(?:_\d+)?/); + + if (match) { + return Number.parseInt(match[1], /* base= */ 10); + } +} + +/* TV Platforms */ + +/** + * Check if the current platform is Tizen. + * + * @returns Determines if current platform is Tizen + */ +export function isTizen(): boolean { + return userAgentContains('Tizen'); +} + +/** + * Check if the current platform is Tizen 2 + * + * @returns Determines if current platform is Tizen 2 + */ +export function isTizen2(): boolean { + return userAgentContains('Tizen 2'); +} + +/** + * Check if the current platform is Tizen 3 + * + * @returns Determines if current platform is Tizen 3 + * @memberof BrowserDetector + */ +export function isTizen3(): boolean { + return userAgentContains('Tizen 3'); +} + +/** + * Check if the current platform is Tizen 4. + * + * @returns Determines if current platform is Tizen 4 + * @memberof BrowserDetector + */ +export function isTizen4(): boolean { + return userAgentContains('Tizen 4'); +} + +/** + * Check if the current platform is Tizen 5. + * + * @returns Determines if current platform is Tizen 5 + * @memberof BrowserDetector + */ +export function isTizen5(): boolean { + return userAgentContains('Tizen 5'); +} + +/** + * Check if the current platform is Tizen 5.5. + * + * @returns Determines if current platform is Tizen 5.5 + * @memberof BrowserDetector + */ +export function isTizen55(): boolean { + return userAgentContains('Tizen 5.5'); +} + +/** + * Check if the current platform is WebOS. + * + * @returns Determines if current platform is WebOS + * @memberof BrowserDetector + */ +export function isWebOS(): boolean { + return userAgentContains('Web0S'); +} + +/** + * Determines if current platform is WebOS1 + */ +export function isWebOS1(): boolean { + return ( + isWebOS() && + userAgentContains('AppleWebKit/537') && + !userAgentContains('Chrome/') + ); +} + +/** + * Determines if current platform is WebOS2 + */ +export function isWebOS2(): boolean { + return ( + isWebOS() && + userAgentContains('AppleWebKit/538') && + !userAgentContains('Chrome/') + ); +} + +/** + * Determines if current platform is WebOS3 + */ +export function isWebOS3(): boolean { + return isWebOS() && userAgentContains('Chrome/38'); +} + +/** + * Determines if current platform is WebOS4 + */ +export function isWebOS4(): boolean { + return isWebOS() && userAgentContains('Chrome/53'); +} + +/** + * Determines if current platform is WebOS5 + */ +export function isWebOS5(): boolean { + return isWebOS() && userAgentContains('Chrome/68'); +} + +/* Platform Utilities */ + +/** + * Determines if current platform is Android + */ +export function isAndroid(): boolean { + return userAgentContains('Android'); +} + +/** + * Guesses if the platform is a mobile one (iOS or Android). + * + * @returns Determines if current platform is mobile (Guess) + */ +export function isMobile(): boolean { + let userAgent = ''; + + if (navigator.userAgent) { + userAgent = navigator.userAgent; + } + + if (/iPhone|iPad|iPod|Android/.test(userAgent)) { + // This is Android, iOS, or iPad < 13. + return true; + } + + // Starting with iOS 13 on iPad, the user agent string no longer has the + // word "iPad" in it. It looks very similar to desktop Safari. This seems + // to be intentional on Apple's part. + // See: https://forums.developer.apple.com/thread/119186 + // + // So if it's an Apple device with multi-touch support, assume it's a mobile + // device. If some future iOS version starts masking their user agent on + // both iPhone & iPad, this clause should still work. If a future + // multi-touch desktop Mac is released, this will need some adjustment. + return isApple() && navigator.maxTouchPoints > 1; +} + +/** + * Guesses if the platform is a Smart TV (Tizen or WebOS). + * + * @returns Determines if platform is a Smart TV + */ +export function isTv(): boolean { + return isTizen() || isWebOS(); +} + +/** + * Guesses if the platform is a PS4 + * + * @returns Determines if the device is a PS4 + */ +export function isPs4(): boolean { + return userAgentContains('playstation 4'); +} + +/** + * Guesses if the platform is a Xbox + * + * @returns Determines if the device is a Xbox + */ +export function isXbox(): boolean { + return userAgentContains('xbox'); +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 48cd4a1..13f180f 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -28,7 +28,9 @@ {#if movies[index]} - {#await Promise.all(movies) then awaitedMovies} + {#await Promise.all(movies)} +
+ {:then awaitedMovies} {/if} {#await data.streamed.continueWatching then continueWatching} diff --git a/src/routes/components/Poster/Poster.svelte b/src/routes/components/Poster/Poster.svelte index 242a085..dabc609 100644 --- a/src/routes/components/Poster/Poster.svelte +++ b/src/routes/components/Poster/Poster.svelte @@ -34,6 +34,7 @@ streamFetching = true; getJellyfinItemByTmdbId(tmdbId).then((item: any) => { if (item.Id) streamJellyfinId(item.Id); + streamFetching = false; }); } @@ -54,6 +55,7 @@
(window.location.href = '/' + type + '/' + tmdbId)} >

@@ -73,9 +75,10 @@
Stream Details - import { fetchJellyfinPlaybackUrl } from '$lib/jellyfin/jellyfin'; + import { + getJellyfinItem, + getJellyfinPlaybackInfo, + reportJellyfinPlaybackProgress, + reportJellyfinPlaybackStarted, + reportJellyfinPlaybackStopped + } from '$lib/jellyfin/jellyfin'; import Hls from 'hls.js'; import Modal from '../Modal/Modal.svelte'; import IconButton from '../IconButton.svelte'; import { Cross2 } from 'radix-icons-svelte'; import classNames from 'classnames'; - import { getContext } from 'svelte'; + import { getContext, onDestroy } from 'svelte'; import { PUBLIC_JELLYFIN_URL } from '$env/static/public'; + import getDeviceProfile from '$lib/jellyfin/playback-profiles'; const { playerState, close } = getContext('player'); let video: HTMLVideoElement; - const fetchPlaybackInfo = (id: string) => - fetchJellyfinPlaybackUrl(id).then((uri) => { - if (!uri) return; + let stopCallback; - const hls = new Hls(); + let progressInterval; + onDestroy(() => clearInterval(progressInterval)); - hls.loadSource(PUBLIC_JELLYFIN_URL + uri); - hls.attachMedia(video); - video.play(); - }); + const fetchPlaybackInfo = (itemId: string) => + getJellyfinPlaybackInfo(itemId, getDeviceProfile()).then( + async ({ playbackUrl: uri, playSessionId: sessionId, mediaSourceId }) => { + if (!uri || !sessionId) return; + + const item = await getJellyfinItem(itemId); + + const hls = new Hls(); + + hls.loadSource(PUBLIC_JELLYFIN_URL + uri); + hls.attachMedia(video); + video.play().then(() => { + console.log(item); + if (item?.UserData?.PlaybackPositionTicks) { + console.log('Setting time'); + video.currentTime = item?.UserData?.PlaybackPositionTicks / 10_000_000; + } + }); + await reportJellyfinPlaybackStarted(itemId, sessionId, mediaSourceId); + progressInterval = setInterval(() => { + reportJellyfinPlaybackProgress( + itemId, + sessionId, + video?.paused == true, + video?.currentTime * 10_000_000 + ); + }, 5000); + stopCallback = () => { + reportJellyfinPlaybackStopped(itemId, sessionId, video?.currentTime * 10_000_000); + }; + } + ); function handleClose() { close(); video?.pause(); + clearInterval(progressInterval); + stopCallback?.(); + playerState.set({ visible: false, jellyfinId: '' }); } let uiVisible = false; @@ -47,7 +84,7 @@ throw new Error('HLS is not supported'); } - fetchPlaybackInfo(state.jellyfinId); + if (video.src === '') fetchPlaybackInfo(state.jellyfinId); } } diff --git a/src/routes/library/+page.svelte b/src/routes/library/+page.svelte index 98b9a70..e027409 100644 --- a/src/routes/library/+page.svelte +++ b/src/routes/library/+page.svelte @@ -3,7 +3,6 @@ import Card from '../components/Card/Card.svelte'; import { TMDB_IMAGES } from '$lib/constants.js'; import CardPlaceholder from '../components/Card/CardPlaceholder.svelte'; - import CardProvider from '../components/Card/CardProvider.svelte'; export let data: PageData; const watched = [];