diff --git a/frontend/locales/en-US.json b/frontend/locales/en-US.json
index cddc5a51764..50defcd1583 100644
--- a/frontend/locales/en-US.json
+++ b/frontend/locales/en-US.json
@@ -1,5 +1,4 @@
{
- "auto": "Automatic",
"3DFormat": "3D format",
"NoMediaSourcesAvailable": "No media sources available",
"actor": "Actor",
@@ -25,6 +24,7 @@
"aspectRatio": "Aspect ratio",
"audio": "Audio",
"audioCodecNotSupported": "The audio codec is not supported",
+ "auto": "Automatic",
"badRequest": "Bad request. Try again",
"books": "Books",
"browserNotSupported": "Your browser is not supported for playing this file.",
@@ -34,6 +34,9 @@
"byArtist": "By {artist}",
"cancel": "Cancel",
"castAndCrew": "Cast & crew",
+ "clipboardFail": "Failed to copy to clipboard",
+ "clipboardSuccess": "Copied to clipboard",
+ "close": "Close",
"collectionEmpty": "This collection is empty",
"collections": "Collections",
"communityRating": "Community rating",
@@ -41,6 +44,7 @@
"connect": "Connect",
"continueListening": "Continue listening",
"continueWatching": "Continue watching",
+ "copyStreamURL": "Copy Stream URL",
"criticRating": "Critic rating",
"customRating": "Custom rating",
"darkModeToggle": "Toggle dark mode",
@@ -61,6 +65,11 @@
"disabled": "Disabled",
"discNumber": "Disc {discNumber}",
"dislikes": "Dislikes",
+ "download": {
+ "download": "Download",
+ "downloadAll": "Download all",
+ "failureToGetURL": "Failed to get URL for selected item"
+ },
"edit": "Edit",
"editMetadata": "Edit metadata",
"editPerson": "Edit person",
@@ -116,8 +125,8 @@
},
"images": "Images",
"incorrectUsernameOrPassword": "Incorrect username or password",
- "instantMixQueued": "Instant mix added to queue",
"instantMix": "Instant mix",
+ "instantMixQueued": "Instant mix added to queue",
"item": {
"artist": {
"albums": "Albums",
@@ -201,20 +210,66 @@
"name": "Audio channels:"
},
"audioCodec": {
- "name": "Audio codec:"
- },
- "bitrate": {
- "name": "Bitrate:"
- },
- "container": {
- "name": "Container:"
+ "channels": "Channels:",
+ "layout": "Layout:",
+ "name": "Audio codec:",
+ "sampleRate": "Sample rate:",
+ "titles": "Audio | Audio {0}"
+ },
+ "embeddedImageCodec": {
+ "name": "Image codec:",
+ "titles": "Image | Image {0}"
+ },
+ "generic": {
+ "bitrate": "Bitrate:",
+ "codec": "Codec:",
+ "codecTag": "Codec tag:",
+ "container": "Container:",
+ "default": "Default:",
+ "external": "External:",
+ "forced": "Forced:",
+ "language": "Language:",
+ "path": "Path:",
+ "profile": "Profile:",
+ "size": "Size:",
+ "title": "Title:"
},
"name": "Media",
"subtitleCodec": {
- "name": "Subtitle codec:"
+ "name": "Subtitle codec:",
+ "titles": "Subtitle | Subtitle {0}"
},
+ "title": "Media Info",
"videoCodec": {
- "name": "Video codec:"
+ "DoVi": {
+ "blPresent": "DV bl preset flag:",
+ "blSignalCompatibilityId": "DV bl signal compatibility ID:",
+ "elPresent": "DV el preset flag:",
+ "level": "DV level:",
+ "majorVersion": "DV version major:",
+ "minorVersion": "DV version minor:",
+ "profile": "DV profile:",
+ "rpuPresent": "DV rpu preset flag:",
+ "title": "DV title:"
+ },
+ "aspectRatio": "Aspect ratio:",
+ "bitdepth": "Bit depth:",
+ "colorPrimaries": "Color primaries:",
+ "colorRange": "Color range:",
+ "colorSpace": "Color space:",
+ "colorTransfer": "Color transfer:",
+ "frameRate": "Framerate:",
+ "isAnamorphic": "Anamorphic:",
+ "isAvc": "AVC:",
+ "isInterlaced": "Interlaced:",
+ "level": "Level:",
+ "name": "Video codec:",
+ "pixelFormat": "Pixel format:",
+ "refFrames": "Ref frames:",
+ "resolution": "Resolution:",
+ "titles": "Video | Video {0}",
+ "videoRange": "Video range:",
+ "videoRangeType": "Video range type:"
}
},
"menu": "Menu",
@@ -340,9 +395,9 @@
"refreshKeysFailure": "Error refreshing API keys",
"revoke": "Revoke",
"revokeAll": "Revoke all API keys",
- "revokeConfirm": "Confirm API key revocation",
"revokeAllFailure": "Error revoking all API keys",
"revokeAllSuccess": "Successfully revoked all API keys",
+ "revokeConfirm": "Confirm API key revocation",
"revokeFailure": "Error revoking API key",
"revokeSuccess": "Successfully revoked API key"
},
@@ -351,9 +406,9 @@
"appVersion": "App version",
"delete": "Delete",
"deleteAll": "Delete all",
- "deleteConfirm": "Confirm device deletion",
"deleteAllDevicesError": "Error deleting all devices",
"deleteAllDevicesSuccess": "All devices deleted successfully",
+ "deleteConfirm": "Confirm device deletion",
"deleteDeviceError": "Error deleting device",
"deleteDeviceSuccess": "Device deleted successfully",
"deviceName": "Device name",
@@ -468,9 +523,9 @@
"themeVideo": "Theme Video",
"tooltips": {
"changeLanguage": "Language",
+ "switchToAuto": "Follow system theme",
"switchToDarkMode": "Switch to dark mode",
- "switchToLightMode": "Switch to light mode",
- "switchToAuto": "Follow system theme"
+ "switchToLightMode": "Switch to light mode"
},
"trailer": "Trailer",
"transcodingInfo": {
@@ -515,7 +570,6 @@
"undefined": "Undefined",
"unexpectedError": "Unexpected error",
"unhandledException": "Unhandled exception",
- "unknown": "Unknown",
"units": {
"bitrate": {
"kbps": "{value} kbps",
@@ -525,6 +579,7 @@
"seconds": "{count} second | {count} seconds"
}
},
+ "unknown": "Unknown",
"unliked": "Unliked",
"unplayed": "Unplayed",
"upNext": "Up next",
diff --git a/frontend/src/components/Item/ItemMenu.vue b/frontend/src/components/Item/ItemMenu.vue
index f893bb3b246..5148230e349 100644
--- a/frontend/src/components/Item/ItemMenu.vue
+++ b/frontend/src/components/Item/ItemMenu.vue
@@ -36,6 +36,10 @@
v-if="item.Id"
v-model:dialog="metadataDialog"
:item-id="item.Id" />
+
+
+
diff --git a/frontend/src/components/Item/MediaDetail/MediaDetailAttr.vue b/frontend/src/components/Item/MediaDetail/MediaDetailAttr.vue
new file mode 100644
index 00000000000..5228e036d0f
--- /dev/null
+++ b/frontend/src/components/Item/MediaDetail/MediaDetailAttr.vue
@@ -0,0 +1,25 @@
+
+
+ {{ label }}
+ {{ value ?? 'Unknown' }}
+
+
+
+
+
+
diff --git a/frontend/src/components/Item/MediaDetail/MediaDetailColorSpace.vue b/frontend/src/components/Item/MediaDetail/MediaDetailColorSpace.vue
new file mode 100644
index 00000000000..c2678e04ec1
--- /dev/null
+++ b/frontend/src/components/Item/MediaDetail/MediaDetailColorSpace.vue
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/Item/MediaDetail/MediaDetailContent.vue b/frontend/src/components/Item/MediaDetail/MediaDetailContent.vue
new file mode 100644
index 00000000000..066315279f4
--- /dev/null
+++ b/frontend/src/components/Item/MediaDetail/MediaDetailContent.vue
@@ -0,0 +1,391 @@
+
+
+
+ {{ displayName }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('mediaInfo.videoCodec.titles', [idx + 1], videoStreams.length) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('mediaInfo.audioCodec.titles', [idx + 1], audioStreams.length) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ t('mediaInfo.subtitleCodec.titles', [idx + 1], subsStreams.length)
+ }}
+
+
+
+
+
+
+
+
+
+ {{
+ t(
+ 'mediaInfo.embeddedImageCodec.titles',
+ [idx + 1],
+ embeddedStreams.length
+ )
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/Item/MediaDetail/MediaDetailCopy.vue b/frontend/src/components/Item/MediaDetail/MediaDetailCopy.vue
new file mode 100644
index 00000000000..0b7b6d855e0
--- /dev/null
+++ b/frontend/src/components/Item/MediaDetail/MediaDetailCopy.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/Item/MediaDetail/MediaDetailDialog.vue b/frontend/src/components/Item/MediaDetail/MediaDetailDialog.vue
new file mode 100644
index 00000000000..bd263e8fac0
--- /dev/null
+++ b/frontend/src/components/Item/MediaDetail/MediaDetailDialog.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/Item/MediaDetail/MediaDetailExtras.vue b/frontend/src/components/Item/MediaDetail/MediaDetailExtras.vue
new file mode 100644
index 00000000000..db4737a10a1
--- /dev/null
+++ b/frontend/src/components/Item/MediaDetail/MediaDetailExtras.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/Item/MediaDetail/MediaDetailGeneric.vue b/frontend/src/components/Item/MediaDetail/MediaDetailGeneric.vue
new file mode 100644
index 00000000000..4993a8db719
--- /dev/null
+++ b/frontend/src/components/Item/MediaDetail/MediaDetailGeneric.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/Playback/TrackList.vue b/frontend/src/components/Playback/TrackList.vue
index f7fb8f17cfc..b6aee3d034e 100644
--- a/frontend/src/components/Playback/TrackList.vue
+++ b/frontend/src/components/Playback/TrackList.vue
@@ -69,7 +69,7 @@
-
+
@@ -113,7 +113,11 @@ async function fetch(): Promise {
parentId: props.item.Id,
sortBy: ['SortName'],
sortOrder: [SortOrder.Ascending],
- fields: [ItemFields.CanDelete]
+ fields: [
+ ItemFields.MediaSources,
+ ItemFields.CanDelete,
+ ItemFields.CanDownload
+ ]
})
).data.Items;
}
diff --git a/frontend/src/store/userLibraries.ts b/frontend/src/store/userLibraries.ts
index 0a9cd4ac54c..d954b0ba567 100644
--- a/frontend/src/store/userLibraries.ts
+++ b/frontend/src/store/userLibraries.ts
@@ -148,7 +148,12 @@ class UserLibrariesStore {
await remote.sdk.newUserApi(getItemsApi).getResumeItems({
userId: remote.auth.currentUserId || '',
limit: 24,
- fields: [ItemFields.PrimaryImageAspectRatio, ItemFields.CanDelete],
+ fields: [
+ ItemFields.PrimaryImageAspectRatio,
+ ItemFields.MediaSources,
+ ItemFields.CanDelete,
+ ItemFields.CanDownload
+ ],
imageTypeLimit: 1,
enableImageTypes: [
ImageType.Primary,
@@ -174,7 +179,12 @@ class UserLibrariesStore {
await remote.sdk.newUserApi(getItemsApi).getResumeItems({
userId: remote.auth.currentUserId || '',
limit: 24,
- fields: [ItemFields.PrimaryImageAspectRatio, ItemFields.CanDelete],
+ fields: [
+ ItemFields.PrimaryImageAspectRatio,
+ ItemFields.MediaSources,
+ ItemFields.CanDelete,
+ ItemFields.CanDownload
+ ],
imageTypeLimit: 1,
enableImageTypes: [
ImageType.Primary,
@@ -200,7 +210,12 @@ class UserLibrariesStore {
await remote.sdk.newUserApi(getTvShowsApi).getNextUp({
userId: remote.auth.currentUserId,
limit: 24,
- fields: [ItemFields.PrimaryImageAspectRatio, ItemFields.CanDelete],
+ fields: [
+ ItemFields.PrimaryImageAspectRatio,
+ ItemFields.MediaSources,
+ ItemFields.CanDelete,
+ ItemFields.CanDownload
+ ],
imageTypeLimit: 1,
enableImageTypes: [
ImageType.Primary,
@@ -228,7 +243,12 @@ class UserLibrariesStore {
await remote.sdk.newUserApi(getUserLibraryApi).getLatestMedia({
userId: remote.auth.currentUserId || '',
limit: 24,
- fields: [ItemFields.PrimaryImageAspectRatio, ItemFields.CanDelete],
+ fields: [
+ ItemFields.PrimaryImageAspectRatio,
+ ItemFields.MediaSources,
+ ItemFields.CanDelete,
+ ItemFields.CanDownload
+ ],
imageTypeLimit: 1,
enableImageTypes: [
ImageType.Primary,
@@ -256,7 +276,9 @@ class UserLibrariesStore {
fields: [
ItemFields.Overview,
ItemFields.PrimaryImageAspectRatio,
- ItemFields.CanDelete
+ ItemFields.MediaSources,
+ ItemFields.CanDelete,
+ ItemFields.CanDownload
],
enableImageTypes: [ImageType.Backdrop, ImageType.Logo],
imageTypeLimit: 1
diff --git a/frontend/src/utils/browser-detection.ts b/frontend/src/utils/browser-detection.ts
index 0fa8d01e535..3df68d1d3b8 100644
--- a/frontend/src/utils/browser-detection.ts
+++ b/frontend/src/utils/browser-detection.ts
@@ -28,10 +28,16 @@ export function supportsMediaSource(): boolean {
* @private
* @static
* @param key - Key for which to perform a check.
+ * @param caseSensitive - Whether the check should be case sensitive.
* @returns Determines if user agent of navigator contains a key
*/
-function userAgentContains(key: string): boolean {
- const userAgent = navigator.userAgent || '';
+function userAgentContains(key: string, caseSensitive = true): boolean {
+ let userAgent = navigator.userAgent || '';
+
+ if (!caseSensitive) {
+ key = key.toLowerCase();
+ userAgent = userAgent.toLowerCase();
+ }
return userAgent.includes(key);
}
@@ -57,6 +63,22 @@ export function isEdge(): boolean {
return userAgentContains('Edg/') || userAgentContains('Edge/');
}
+/**
+ * Check if the current platform is Microsoft Edge UWP.
+ *
+ * @static
+ * @returns Determines if browser is Microsoft Edge UWP.
+ */
+export function isEdgeUWP(): boolean {
+ if (!isEdge()) {
+ return false;
+ }
+
+ return (
+ userAgentContains('msapphost', false) || userAgentContains('webview', false)
+ );
+}
+
/**
* Check if the current platform is Chromium based.
*
diff --git a/frontend/src/utils/clipboard.ts b/frontend/src/utils/clipboard.ts
new file mode 100644
index 00000000000..ca687ed0de5
--- /dev/null
+++ b/frontend/src/utils/clipboard.ts
@@ -0,0 +1,8 @@
+/**
+ * Write text to the clipboard.
+ *
+ * @param text - The text to write to the clipboard.
+ */
+export async function writeToClipboard(text: string): Promise {
+ await navigator.clipboard.writeText(text);
+}
diff --git a/frontend/src/utils/file-download.ts b/frontend/src/utils/file-download.ts
new file mode 100644
index 00000000000..ea8fe3b52c4
--- /dev/null
+++ b/frontend/src/utils/file-download.ts
@@ -0,0 +1,88 @@
+import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
+import { isEdgeUWP, isFirefox, isPs4, isTv, isXbox } from './browser-detection';
+
+export interface DownloadableFile {
+ // The file URL
+ url: string;
+ // The filename, including the file extension
+ fileName: string;
+}
+
+/**
+ * Check if the url is on the same domain as the current page.
+ *
+ * @param url - The url to check.
+ */
+function sameDomain(url: string): boolean {
+ const a = document.createElement('a');
+
+ a.href = url;
+
+ return (
+ window.location.hostname === a.hostname &&
+ window.location.protocol === a.protocol
+ );
+}
+
+/**
+ * Use html tag to download a file.
+ *
+ * @param file - An object with `url` and `fileName` properties.
+ */
+function downloadBrowser(file: DownloadableFile): void {
+ const a = document.createElement('a');
+
+ a.download = file.fileName;
+ a.href = file.url;
+ // firefox doesn't support `a.click()`...
+ a.dispatchEvent(new MouseEvent('click'));
+}
+
+/**
+ * Check if the browser are able to download the item.
+ *
+ * @param item - The item to check.
+ */
+export function canBrowserDownloadItem(item: BaseItemDto): boolean {
+ return (
+ !isEdgeUWP() && !isTv() && !isXbox() && !isPs4() && item.Type !== 'Book'
+ );
+}
+
+/**
+ * Download multiple files.
+ *
+ * @param filesToDownload - An array of objects with `url` and `fileName` properties.
+ */
+export async function downloadFiles(
+ filesToDownload: DownloadableFile | DownloadableFile[]
+): Promise {
+ if (!filesToDownload) {
+ throw new Error('`filesToDownload` required');
+ }
+
+ const files = Array.isArray(filesToDownload)
+ ? filesToDownload
+ : [filesToDownload];
+
+ if (files.length === 0) {
+ throw new Error(
+ '`filesToDownload` must be an array with at least one item'
+ );
+ }
+
+ if (document.createElement('a').download === undefined) {
+ throw new Error('Browser does not support downloading files');
+ }
+
+ let delay = 0;
+
+ for (const file of files) {
+ if (isFirefox() && !sameDomain(file.url)) {
+ // the download init has to be sequential for firefox if the urls are not on the same domain
+ setTimeout(downloadBrowser.bind(undefined, file), 100 * ++delay);
+ } else {
+ downloadBrowser(file);
+ }
+ }
+}
diff --git a/frontend/src/utils/items.ts b/frontend/src/utils/items.ts
index 0158d9cee47..3f93e34366c 100644
--- a/frontend/src/utils/items.ts
+++ b/frontend/src/utils/items.ts
@@ -5,6 +5,7 @@ import {
BaseItemDto,
BaseItemKind,
BaseItemPerson,
+ ItemFields,
MediaStream
} from '@jellyfin/sdk/lib/generated-client';
import { useRouter } from 'vue-router';
@@ -26,6 +27,10 @@ import IMdiBookMusic from 'virtual:icons/mdi/book-music';
import IMdiFolderMultiple from 'virtual:icons/mdi/folder-multiple';
import IMdiFilmstrip from 'virtual:icons/mdi/filmstrip';
import IMdiAlbum from 'virtual:icons/mdi/album';
+import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api';
+import { getTvShowsApi } from '@jellyfin/sdk/lib/utils/api/tv-shows-api';
+import { DownloadableFile } from './file-download';
+import { useRemote } from '@/composables';
/**
* A list of valid collections that should be treated as folders.
@@ -400,3 +405,120 @@ export function getMediaStreams(
): MediaStream[] {
return mediaStreams.filter((mediaStream) => mediaStream.Type === streamType);
}
+
+/**
+ * Create an item download object that contains the URL and filename.
+ *
+ * @param itemId - The item ID.
+ * @param itemPath - The item path.
+ * @returns - A download object.
+ */
+export function getItemDownloadObject(
+ itemId: string,
+ itemPath?: string
+): DownloadableFile | undefined {
+ const remote = useRemote();
+
+ const serverAddress = remote.sdk.api?.basePath;
+ const userToken = remote.sdk.api?.accessToken;
+
+ if (!serverAddress || !userToken) {
+ return undefined;
+ }
+
+ const fileName = itemPath?.includes('\\')
+ ? itemPath?.split('\\').pop()
+ : itemPath?.split('/').pop();
+
+ return {
+ url: `${serverAddress}/Items/${itemId}/Download?api_key=${userToken}`,
+ fileName: fileName || ''
+ };
+}
+
+/**
+ * Get multiple download object for seasons.
+ *
+ * @param seasonId - The season ID.
+ * @returns - An array of download objects.
+ */
+export async function getItemSeasonDownloadObjects(
+ seasonId: string
+): Promise {
+ const remote = useRemote();
+
+ if (remote.sdk.api === undefined) {
+ return [];
+ }
+
+ const episodes = (
+ await remote.sdk.newUserApi(getItemsApi).getItems({
+ userId: remote.auth.currentUserId,
+ parentId: seasonId,
+ fields: [ItemFields.Overview, ItemFields.CanDownload, ItemFields.Path]
+ })
+ ).data;
+
+ return (
+ episodes.Items?.map((r) => {
+ if (r.Id && r.Path) {
+ return getItemDownloadObject(r.Id, r.Path);
+ }
+ }).filter(
+ (r): r is DownloadableFile =>
+ r !== undefined && r.url.length > 0 && r.fileName.length > 0
+ ) ?? []
+ );
+}
+
+/**
+ * Get download object for a series.
+ * This will fetch every season for all the episodes.
+ *
+ * @param seriesId - The series ID.
+ * @returns - An array of download objects.
+ */
+export async function getItemSeriesDownloadObjects(
+ seriesId: string
+): Promise {
+ const remote = useRemote();
+
+ let mergedStreamURLs: DownloadableFile[] = [];
+
+ if (remote.sdk.api === undefined) {
+ return [];
+ }
+
+ const seasons = (
+ await remote.sdk.newUserApi(getTvShowsApi).getSeasons({
+ userId: remote.auth.currentUserId,
+ seriesId: seriesId
+ })
+ ).data;
+
+ for (const season of seasons.Items || []) {
+ const seasonURLs = await getItemSeasonDownloadObjects(season.Id || '');
+
+ mergedStreamURLs = [...mergedStreamURLs, ...seasonURLs];
+ }
+
+ return mergedStreamURLs;
+}
+
+/**
+ * Format a number of bytes into a human readable string
+ *
+ * @param size - The number of bytes to format
+ * @returns - A human readable string
+ */
+export function formatFileSize(size: number): string {
+ if (size === 0) {
+ return '0 B';
+ }
+
+ const i = Math.floor(Math.log(size) / Math.log(1024));
+
+ return `${(size / Math.pow(1024, i)).toFixed(2)} ${
+ ['B', 'kiB', 'MiB', 'GiB', 'TiB', 'PiB'][i]
+ }`;
+}
diff --git a/frontend/src/utils/mediainfo.ts b/frontend/src/utils/mediainfo.ts
new file mode 100644
index 00000000000..550d48aa951
--- /dev/null
+++ b/frontend/src/utils/mediainfo.ts
@@ -0,0 +1,354 @@
+import {
+ MediaSourceInfo,
+ MediaStream
+} from '@jellyfin/sdk/lib/generated-client';
+import { isBoolean, isNil } from 'lodash-es';
+import { formatFileSize } from './items';
+import { usei18n } from '@/composables';
+
+const { t } = usei18n();
+const profileT = t('mediaInfo.generic.profile');
+
+type MediaItem = string | number | boolean;
+type NoneType = null | undefined;
+
+/**
+ * Format a boolean value into a Yes/No string.
+ * @param value - The boolean value to format.
+ */
+export function formatYesOrNo(value: boolean | NoneType): string {
+ return value ? 'Yes' : 'No';
+}
+
+/**
+ * Check if a value is a valid media item.
+ */
+function checkValidValue(value: MediaItem | NoneType): value is MediaItem {
+ if (isNil(value)) {
+ return false;
+ }
+
+ if (typeof value === 'string') {
+ return value.trim().length > 0;
+ }
+
+ return true;
+}
+
+/**
+ * Format a media attribute into a mediainfo text format.
+ * @param key - The key of the attribute.
+ * @param value - The value of the attribute.
+ * @param suffix - An optional suffix to append to the attribute.
+ */
+function formatMediaAttr(
+ key: string,
+ value: MediaItem | NoneType,
+ suffix?: string
+): string {
+ return checkValidValue(value)
+ ? `${key} ${value} ${suffix ?? ''}`.trimEnd() + '\n'
+ : '';
+}
+
+/**
+ * Create generic information about the media stream.
+ * @param stream - The media stream to create information for.
+ */
+function createGenericInfo(stream: MediaStream): string {
+ let mediaInfo = '';
+
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.generic.title'),
+ stream.DisplayTitle
+ );
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.generic.language'),
+ stream.Language
+ );
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.generic.codec'),
+ stream.Codec?.toUpperCase()
+ );
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.generic.codecTag'),
+ stream.CodecTag
+ );
+
+ return mediaInfo;
+}
+
+/**
+ * Create generic information about the Default/Forced/External status of a stream.
+ * @param stream - The media stream to create information for.
+ */
+function createExtraInformation(stream: MediaStream): string {
+ let mediaInfo = '';
+
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.generic.default'),
+ formatYesOrNo(stream.IsDefault)
+ );
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.generic.forced'),
+ formatYesOrNo(stream.IsForced)
+ );
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.generic.external'),
+ formatYesOrNo(stream.IsExternal)
+ );
+
+ return mediaInfo;
+}
+
+/**
+ * Create information about Dolby Vision if exist.
+ * @param stream - The media stream to create information for.
+ */
+function createVideoDoViInformation(stream: MediaStream): string {
+ let mediaInfo = '';
+
+ if (typeof stream.VideoDoViTitle !== 'string') {
+ return mediaInfo;
+ }
+
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.videoCodec.DoVi.title'),
+ stream.VideoDoViTitle
+ );
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.videoCodec.DoVi.majorVersion'),
+ stream.DvVersionMajor
+ );
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.videoCodec.DoVi.minorVersion'),
+ stream.DvVersionMinor
+ );
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.videoCodec.DoVi.profile'),
+ stream.DvProfile
+ );
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.videoCodec.DoVi.level'),
+ stream.DvLevel
+ );
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.videoCodec.DoVi.rpuPresent'),
+ stream.RpuPresentFlag
+ );
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.videoCodec.DoVi.elPresent'),
+ stream.ElPresentFlag
+ );
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.videoCodec.DoVi.blPresent'),
+ stream.BlPresentFlag
+ );
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.videoCodec.DoVi.blSignalCompatibilityId'),
+ stream.DvBlSignalCompatibilityId
+ );
+
+ return mediaInfo;
+}
+
+/**
+ * Create information about the color space used in the video stream.
+ * @param stream - The media stream to create information for.
+ */
+function createVideoColorInformation(stream: MediaStream): string {
+ let mediaInfo = '';
+
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.videoCodec.colorSpace'),
+ stream.ColorSpace
+ );
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.videoCodec.colorTransfer'),
+ stream.ColorTransfer
+ );
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.videoCodec.colorPrimaries'),
+ stream.ColorPrimaries
+ );
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.videoCodec.colorRange'),
+ stream.ColorRange
+ );
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.videoCodec.pixelFormat'),
+ stream.PixelFormat
+ );
+
+ return mediaInfo;
+}
+
+/**
+ * Format video media info into a mediainfo text format
+ * @param stream - The media stream to create information for.
+ */
+export function createVideoInformation(stream: MediaStream): string {
+ let mediaInfo = createGenericInfo(stream);
+
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.videoCodec.isAvc'),
+ formatYesOrNo(stream.IsAVC)
+ );
+ mediaInfo += formatMediaAttr(profileT, stream.Profile);
+ mediaInfo += formatMediaAttr(t('mediaInfo.videoCodec.level'), stream.Level);
+
+ if (stream.Width || stream.Height) {
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.videoCodec.resolution'),
+ `${stream.Width}x${stream.Height}`
+ );
+ }
+
+ if (stream.AspectRatio) {
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.videoCodec.aspectRatio'),
+ stream.AspectRatio
+ );
+ }
+
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.videoCodec.isAnamorphic'),
+ isBoolean(stream.IsAnamorphic)
+ ? formatYesOrNo(stream.IsAnamorphic)
+ : undefined
+ );
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.videoCodec.isInterlaced'),
+ formatYesOrNo(stream.IsInterlaced)
+ );
+
+ if (stream.AverageFrameRate || stream.RealFrameRate) {
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.videoCodec.frameRate'),
+ (stream.AverageFrameRate || stream.RealFrameRate)?.toFixed(3),
+ 'fps'
+ );
+ }
+
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.generic.bitrate'),
+ stream.BitRate ? (stream.BitRate / 1000).toFixed(2) : '',
+ 'kbps'
+ );
+
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.videoCodec.bitdepth'),
+ stream.BitDepth,
+ 'bits'
+ );
+
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.videoCodec.videoRange'),
+ stream.VideoRange
+ );
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.videoCodec.videoRangeType'),
+ stream.VideoRangeType
+ );
+
+ mediaInfo += createVideoDoViInformation(stream);
+ mediaInfo += createVideoColorInformation(stream);
+ mediaInfo += formatMediaAttr('NAL', stream.NalLengthSize);
+
+ return mediaInfo;
+}
+
+/**
+ * Format audio media info into a mediainfo text format
+ * @param stream - The media stream to create information for.
+ */
+export function createAudioInformation(stream: MediaStream): string {
+ let mediaInfo = createGenericInfo(stream);
+
+ mediaInfo += formatMediaAttr(profileT, stream.Profile);
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.audioCodec.layout'),
+ stream.ChannelLayout
+ );
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.audioCodec.channels'),
+ stream.Channels,
+ 'ch'
+ );
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.generic.bitrate'),
+ stream.BitRate ? (stream.BitRate / 1000).toFixed(2) : '',
+ 'kbps'
+ );
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.audioCodec.sampleRate'),
+ stream.SampleRate,
+ 'Hz'
+ );
+
+ mediaInfo += createExtraInformation(stream);
+
+ return mediaInfo;
+}
+
+/**
+ * Format subtitle media info into a mediainfo text format
+ * @param stream - The media stream to create information for.
+ */
+export function createSubsInformation(stream: MediaStream): string {
+ let mediaInfo = createGenericInfo(stream);
+
+ mediaInfo += createExtraInformation(stream);
+
+ return mediaInfo;
+}
+
+/**
+ * Format embbedded media info into a mediainfo text format
+ * @param stream - The media stream to create information for.
+ */
+export function createEmbeddedInformation(stream: MediaStream): string {
+ let mediaInfo = createGenericInfo(stream);
+
+ mediaInfo += formatMediaAttr(profileT, stream.Profile);
+
+ if (stream.Width || stream.Height) {
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.videoCodec.resolution'),
+ `${stream.Width}x${stream.Height}`
+ );
+ }
+
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.videoCodec.bitdepth'),
+ stream.BitDepth,
+ 'bits'
+ );
+ mediaInfo += createVideoColorInformation(stream);
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.videoCodec.refFrames'),
+ stream.RefFrames
+ );
+
+ return mediaInfo;
+}
+
+/**
+ * Format container media info into a mediainfo text format
+ * @param media - The media source to create information for.
+ */
+export function createContainerInformation(media: MediaSourceInfo): string {
+ let mediaInfo = '';
+
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.generic.container'),
+ media.Container
+ );
+ mediaInfo += formatMediaAttr(t('mediaInfo.generic.path'), media.Path);
+ mediaInfo += formatMediaAttr(
+ t('mediaInfo.generic.size'),
+ media.Size ? formatFileSize(media.Size) : ''
+ );
+
+ return mediaInfo;
+}
diff --git a/frontend/types/global/components.d.ts b/frontend/types/global/components.d.ts
index c20f8df91e1..2c19e6e0a9b 100644
--- a/frontend/types/global/components.d.ts
+++ b/frontend/types/global/components.d.ts
@@ -50,6 +50,7 @@ declare module '@vue/runtime-core' {
IMdiClosedCaptionOutline: typeof import('~icons/mdi/closed-caption-outline')['default']
IMdiCloudDownload: typeof import('~icons/mdi/cloud-download')['default']
IMdiCog: typeof import('~icons/mdi/cog')['default']
+ IMdiContentCopy: typeof import('~icons/mdi/content-copy')['default']
IMdiContentSave: typeof import('~icons/mdi/content-save')['default']
IMdiDelete: typeof import('~icons/mdi/delete')['default']
IMdiDisc: typeof import('~icons/mdi/disc')['default']
@@ -95,6 +96,14 @@ declare module '@vue/runtime-core' {
LocaleSwitcher: typeof import('./../../src/components/System/LocaleSwitcher.vue')['default']
LoginForm: typeof import('./../../src/components/Forms/LoginForm.vue')['default']
MarkPlayedButton: typeof import('./../../src/components/Buttons/MarkPlayedButton.vue')['default']
+ MediaDetail: typeof import('./../../src/components/Item/MediaDetail/MediaDetail.vue')['default']
+ MediaDetailAttr: typeof import('./../../src/components/Item/MediaDetail/MediaDetailAttr.vue')['default']
+ MediaDetailColorSpace: typeof import('./../../src/components/Item/MediaDetail/MediaDetailColorSpace.vue')['default']
+ MediaDetailContent: typeof import('./../../src/components/Item/MediaDetail/MediaDetailContent.vue')['default']
+ MediaDetailCopy: typeof import('./../../src/components/Item/MediaDetail/MediaDetailCopy.vue')['default']
+ MediaDetailDialog: typeof import('./../../src/components/Item/MediaDetail/MediaDetailDialog.vue')['default']
+ MediaDetailExtras: typeof import('./../../src/components/Item/MediaDetail/MediaDetailExtras.vue')['default']
+ MediaDetailGeneric: typeof import('./../../src/components/Item/MediaDetail/MediaDetailGeneric.vue')['default']
MediaInfo: typeof import('./../../src/components/Item/MediaInfo.vue')['default']
MediaStreamSelector: typeof import('./../../src/components/Item/MediaStreamSelector.vue')['default']
MetadataEditor: typeof import('./../../src/components/Item/Metadata/MetadataEditor.vue')['default']
|