feat: 完成绿联媒体服务前端接入与展示优化

This commit is contained in:
doumao
2026-02-28 22:09:09 +08:00
parent 351faf2891
commit ff7658b5ba
14 changed files with 960 additions and 331 deletions

View File

@@ -80,6 +80,10 @@ export const mediaServerOptions = [
value: 'trimemedia',
title: i18n.global.t('setting.system.trimeMedia'),
},
{
value: 'ugreen',
title: i18n.global.t('setting.system.ugreen'),
},
]
export const mediaServerDict = mediaServerOptions.reduce((dict, item) => {

View File

@@ -885,8 +885,8 @@ export interface MediaStatistic {
movie_count: number
// 电视剧总数
tv_count: number
// 电视剧总集数
episode_count: number
// 电视剧总集数,未获取时为 null
episode_count: number | null
// 用户数量
user_count: number
}
@@ -1134,7 +1134,7 @@ export interface StorageConf {
export interface MediaServerConf {
// 名称
name: string
// 类型 emby/jellyfin/plex
// 类型 emby/jellyfin/plex/trimemedia/ugreen
type: string
// 配置
config: { [key: string]: any }

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -1,5 +1,6 @@
<script lang="ts" setup>
import type { MediaServerPlayItem } from '@/api/types'
import noImage from '@images/no-image.jpeg'
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
// 输入参数
const props = defineProps({
@@ -10,12 +11,18 @@ const props = defineProps({
// 图片是否加载完成
const imageLoaded = ref(false)
const imageLoadError = ref(false)
// 图片加载完成响应
function imageLoadHandler() {
imageLoaded.value = true
}
// 图片加载失败响应
function imageErrorHandler() {
imageLoadError.value = true
}
// 跳转播放
async function goPlay() {
if (props.media?.link) {
@@ -26,6 +33,7 @@ async function goPlay() {
// 计算图片地址
const getImgUrl = computed(() => {
const image = props.media?.image || ''
if (!image || imageLoadError.value) return noImage
let url = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
const use_cookies = props.media?.use_cookies
if (use_cookies) {
@@ -50,7 +58,7 @@ const getImgUrl = computed(() => {
@click="goPlay"
>
<template #image>
<VImg :src="getImgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler">
<VImg :src="getImgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler" @error="imageErrorHandler">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />

View File

@@ -33,6 +33,7 @@ function imageLoadHandler() {
// 图片加载错误
function imageErrorHandler() {
imageError.value = true
imgUrl.value = getDefaultImage()
}
// 默认图片
@@ -41,6 +42,7 @@ function getDefaultImage() {
else if (props.media?.server_type === 'emby') return emby
else if (props.media?.server_type === 'jellyfin') return jellyfin
else if (props.media?.server_type === 'trimemedia') return getLogoUrl('trimemedia')
else if (props.media?.server_type === 'ugreen') return getLogoUrl('ugreen')
else return plex
}
@@ -53,7 +55,7 @@ async function goPlay() {
// 生成图片代理路径
function getImgUrl(url: string, use_cookies?: boolean) {
if (!url) return getDefaultImage()
if (!url || imageError.value) return getDefaultImage()
let imgurl = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
if (use_cookies) {
imgurl += `&use_cookies=${encodeURIComponent(use_cookies)}`
@@ -64,7 +66,7 @@ function getImgUrl(url: string, use_cookies?: boolean) {
// 根据多张图片生成媒体库封面
async function drawImages(imageList: string[], use_cookies?: boolean) {
// 图片
const IMAGES = imageList
const IMAGES = [...imageList]
if (IMAGES.length === 0) return getDefaultImage()
// 为所有图片添加system/img前缀

View File

@@ -61,6 +61,12 @@ const librariesOptions = ref<{ title: string; value: string | undefined }[]>([
},
])
const ugreenScanModeOptions = computed(() => [
{ title: t('mediaserver.scanModeOptions.newAndModified'), value: 'new_and_modified' },
{ title: t('mediaserver.scanModeOptions.supplementMissing'), value: 'supplement_missing' },
{ title: t('mediaserver.scanModeOptions.fullOverride'), value: 'full_override' },
])
// 媒体服务器详情弹窗
const mediaServerInfoDialog = ref(false)
@@ -77,6 +83,12 @@ function openMediaServerInfoDialog() {
loadLibrary(props.mediaserver.name)
// 深复制
mediaServerInfo.value = cloneDeep(props.mediaserver)
if (mediaServerInfo.value.type === 'ugreen') {
mediaServerInfo.value.config = mediaServerInfo.value.config || {}
}
if (mediaServerInfo.value.type === 'ugreen' && !mediaServerInfo.value.config.scan_mode) {
mediaServerInfo.value.config.scan_mode = 'supplement_missing'
}
mediaServerInfoDialog.value = true
if (!props.mediaserver.sync_libraries) {
mediaServerInfo.value.sync_libraries = ['all']
@@ -110,6 +122,8 @@ const getIcon = computed(() => {
return getLogoUrl('jellyfin')
case 'trimemedia':
return getLogoUrl('trimemedia')
case 'ugreen':
return getLogoUrl('ugreen')
case 'plex':
return getLogoUrl('plex')
default:
@@ -424,6 +438,85 @@ onMounted(() => {
/>
</VCol>
</VRow>
<VRow v-else-if="mediaServerInfo.type == 'ugreen'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
:label="t('common.name')"
:placeholder="t('mediaserver.nameRequired')"
:hint="t('mediaserver.serverAlias')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
:label="t('mediaserver.host')"
:placeholder="t('mediaserver.hostPlaceholder')"
:hint="t('mediaserver.hostHint')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="mediaServerInfo.config.play_host"
:label="t('mediaserver.playHost')"
:placeholder="t('mediaserver.playHostPlaceholder')"
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-play-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.username"
:label="t('mediaserver.username')"
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
type="password"
v-model="mediaServerInfo.config.password"
:label="t('mediaserver.password')"
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
<VCol cols="12">
<VAutocomplete
v-model="mediaServerInfo.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
chips
multiple
clearable
:hint="t('mediaserver.syncLibrariesHint')"
persistent-hint
active
append-inner-icon="mdi-refresh"
prepend-inner-icon="mdi-library"
@click:append-inner="loadLibrary(mediaServerInfo.name)"
/>
</VCol>
<VCol cols="12" md="6">
<VSelect
v-model="mediaServerInfo.config.scan_mode"
:label="t('mediaserver.scanMode')"
:items="ugreenScanModeOptions"
:hint="t('mediaserver.scanModeHint')"
persistent-hint
active
prepend-inner-icon="mdi-radar"
/>
</VCol>
</VRow>
<VRow v-else-if="mediaServerInfo.type == 'plex'">
<VCol cols="12" md="6">
<VTextField

View File

@@ -188,6 +188,7 @@ export function useSetupWizard() {
'jellyfin': 'JellyfinModule',
'plex': 'PlexModule',
'trimemedia': 'TrimeMediaModule',
'ugreen': 'UgreenModule',
},
// 通知映射
notification: {
@@ -405,7 +406,7 @@ export function useSetupWizard() {
errors.push(t('mediaserver.tokenRequired'))
validationErrors.value.mediaServer.token = true
}
} else if (wizardData.value.mediaServer.type === 'trimemedia') {
} else if (wizardData.value.mediaServer.type === 'trimemedia' || wizardData.value.mediaServer.type === 'ugreen') {
if (!wizardData.value.mediaServer.config?.username?.trim()) {
errors.push(t('mediaserver.usernameRequired'))
validationErrors.value.mediaServer.username = true

View File

@@ -46,6 +46,7 @@ export default {
unsubscribe: 'Unsubscribe',
media: 'Media',
unknown: 'Unknown',
notFetched: 'Not Fetched',
notice: 'Notice',
itemsPerPage: 'Items per page',
pageText: '{0}-{1} of {2}',
@@ -314,7 +315,8 @@ export default {
settingTabs: {
system: {
title: 'System',
description: 'Basic settings, downloaders (Qbittorrent, Transmission), media servers (Emby, Jellyfin, Plex)',
description:
'Basic settings, downloaders (Qbittorrent, Transmission), media servers (Emby, Jellyfin, Plex, TrimeMedia, Ugreen)',
},
directory: {
title: 'Storage & Directories',
@@ -1341,6 +1343,7 @@ export default {
emby: 'Emby',
jellyfin: 'Jellyfin',
plex: 'Plex',
ugreen: 'Ugreen',
reloadSuccess: 'System configuration has taken effect',
reloadFailed: 'Failed to reload system!',
auxAuthEnable: 'User Auxiliary Authentication',
@@ -2853,6 +2856,13 @@ export default {
password: 'Password',
syncLibraries: 'Sync Libraries',
syncLibrariesHint: 'Only selected libraries will be synchronized',
scanMode: 'Scan Mode',
scanModeHint: 'Applies to full-library and targeted refresh: New & Modified / Supplement Missing / Full Override',
scanModeOptions: {
newAndModified: 'New & Modified',
supplementMissing: 'Supplement Missing',
fullOverride: 'Full Override',
},
hostRequired: 'Host cannot be empty',
apiKeyRequired: 'API Key cannot be empty',
tokenRequired: 'Token cannot be empty',
@@ -3160,7 +3170,8 @@ export default {
title: 'Media Server',
description: 'Configure media server',
info: 'Media Server Configuration',
infoDesc: 'Configure media server for media library management, can choose Emby, Jellyfin or Plex etc.',
infoDesc:
'Configure media server for media library management, can choose Emby, Jellyfin, Plex, TrimeMedia or Ugreen.',
type: 'Media Server Type',
typeHint: 'Select the type of media server to use',
name: 'Server Name',

View File

@@ -46,6 +46,7 @@ export default {
unsubscribe: '取消订阅',
media: '媒体',
unknown: '未知',
notFetched: '未获取',
notice: '注意',
itemsPerPage: '每页条数',
pageText: '{0}-{1} 共 {2} 条',
@@ -313,7 +314,7 @@ export default {
settingTabs: {
system: {
title: '系统',
description: '基础设置、下载器Qbittorrent、Transmission、媒体服务器Emby、Jellyfin、Plex',
description: '基础设置、下载器Qbittorrent、Transmission、媒体服务器Emby、Jellyfin、Plex、飞牛影视、绿联影视',
},
directory: {
title: '存储 & 目录',
@@ -1336,6 +1337,7 @@ export default {
emby: 'Emby',
jellyfin: 'Jellyfin',
plex: 'Plex',
ugreen: '绿联影视',
reloadSuccess: '系统配置已生效',
reloadFailed: '重载系统失败!',
auxAuthEnable: '用户辅助认证',
@@ -2817,6 +2819,13 @@ export default {
password: '密码',
syncLibraries: '同步媒体库',
syncLibrariesHint: '只有选中的媒体库才会被同步',
scanMode: '扫描模式',
scanModeHint: '用于全库刷新和按库刷新:新添加和修改 / 补充缺失 / 覆盖扫描',
scanModeOptions: {
newAndModified: '新添加和修改',
supplementMissing: '补充缺失',
fullOverride: '覆盖扫描',
},
nameExists: '【{name}】已存在,请替换为其他名称',
hostRequired: '地址不能为空',
apiKeyRequired: 'API密钥不能为空',
@@ -3123,7 +3132,7 @@ export default {
title: '媒体服务器',
description: '配置媒体服务器',
info: '媒体服务器配置说明',
infoDesc: '配置媒体服务器用于媒体库管理可选择Emby、JellyfinPlex',
infoDesc: '配置媒体服务器用于媒体库管理可选择Emby、JellyfinPlex、飞牛影视或绿联影视',
type: '媒体服务器类型',
typeHint: '选择要使用的媒体服务器类型',
name: '服务器名称',

View File

@@ -46,6 +46,7 @@ export default {
unsubscribe: '取消訂閱',
media: '媒體',
unknown: '未知',
notFetched: '未獲取',
notice: '注意',
itemsPerPage: '每頁條數',
pageText: '{0}-{1} 共 {2} 條',
@@ -313,7 +314,7 @@ export default {
settingTabs: {
system: {
title: '系統',
description: '基礎設置、下載器Qbittorrent、Transmission、媒體服務器Emby、Jellyfin、Plex',
description: '基礎設置、下載器Qbittorrent、Transmission、媒體服務器Emby、Jellyfin、Plex、飛牛影視、綠聯影視',
},
directory: {
title: '存儲 & 目錄',
@@ -1337,6 +1338,7 @@ export default {
emby: 'Emby',
jellyfin: 'Jellyfin',
plex: 'Plex',
ugreen: '綠聯影視',
reloadSuccess: '系統配置已生效',
reloadFailed: '重載系統失敗!',
auxAuthEnable: '用戶輔助認證',
@@ -2823,6 +2825,13 @@ export default {
password: '密碼',
syncLibraries: '同步媒體庫',
syncLibrariesHint: '只有選中的媒體庫才會被同步',
scanMode: '掃描模式',
scanModeHint: '用於全庫刷新和按庫刷新:新添加和修改 / 補充缺失 / 覆蓋掃描',
scanModeOptions: {
newAndModified: '新添加和修改',
supplementMissing: '補充缺失',
fullOverride: '覆蓋掃描',
},
nameExists: '【{name}】已存在,請替換為其他名稱',
},
bangumi: {
@@ -3124,7 +3133,7 @@ export default {
title: '媒體伺服器',
description: '設定媒體伺服器',
info: '媒體伺服器設定說明',
infoDesc: '設定媒體伺服器用於媒體庫管理可選擇Emby、JellyfinPlex',
infoDesc: '設定媒體伺服器用於媒體庫管理可選擇Emby、JellyfinPlex、飛牛影視或綠聯影視',
type: '媒體伺服器類型',
typeHint: '選擇要使用的媒體伺服器類型',
name: '伺服器名稱',

View File

@@ -11,6 +11,7 @@ import embyLogo from '@/assets/images/logos/emby.png'
import jellyfinLogo from '@/assets/images/logos/jellyfin.png'
import plexLogo from '@/assets/images/logos/plex.png'
import trimemediaLogo from '@/assets/images/logos/trimemedia.png'
import ugreenLogo from '@/assets/images/logos/ugreen.jpg'
import wechatLogo from '@/assets/images/logos/wechat.png'
import telegramLogo from '@/assets/images/logos/telegram.webp'
import slackLogo from '@/assets/images/logos/slack.webp'
@@ -40,6 +41,7 @@ const logoMap: Record<string, string> = {
jellyfin: jellyfinLogo,
plex: plexLogo,
trimemedia: trimemediaLogo,
ugreen: ugreenLogo,
wechat: wechatLogo,
telegram: telegramLogo,
slack: slackLogo,

View File

@@ -28,7 +28,7 @@ async function loadMediaStatistic() {
},
{
title: t('dashboard.episodes'),
stats: res.episode_count.toLocaleString(),
stats: res.episode_count == null ? t('common.notFetched') : res.episode_count.toLocaleString(),
icon: 'mdi-television-classic',
color: 'warning',
},

View File

@@ -15,6 +15,20 @@ const librariesOptions = ref<{ title: string; value: string | undefined }[]>([
},
])
const ugreenScanModeOptions = computed(() => [
{ title: t('mediaserver.scanModeOptions.newAndModified'), value: 'new_and_modified' },
{ title: t('mediaserver.scanModeOptions.supplementMissing'), value: 'supplement_missing' },
{ title: t('mediaserver.scanModeOptions.fullOverride'), value: 'full_override' },
])
function ensureUgreenScanMode() {
if (wizardData.value.mediaServer.type !== 'ugreen') return
wizardData.value.mediaServer.config = wizardData.value.mediaServer.config || {}
if (!wizardData.value.mediaServer.config.scan_mode) {
wizardData.value.mediaServer.config.scan_mode = 'supplement_missing'
}
}
// 调用API查询媒体库
async function loadLibrary(server: string) {
try {
@@ -42,6 +56,7 @@ async function loadLibrary(server: string) {
// 选择媒体服务器并自动加载媒体库
async function selectMediaServerWithLibrary(type: string) {
selectMediaServer(type)
ensureUgreenScanMode()
// 如果选择了媒体服务器类型,自动加载媒体库
if (type && wizardData.value.mediaServer.name) {
await loadLibrary(wizardData.value.mediaServer.name)
@@ -50,6 +65,7 @@ async function selectMediaServerWithLibrary(type: string) {
// 组件挂载时检查是否需要加载媒体库
onMounted(async () => {
ensureUgreenScanMode()
// 如果已经有媒体服务器配置,自动加载媒体库
if (wizardData.value.mediaServer.type && wizardData.value.mediaServer.name) {
await loadLibrary(wizardData.value.mediaServer.name)
@@ -60,6 +76,7 @@ onMounted(async () => {
watch(
() => [wizardData.value.mediaServer.type, wizardData.value.mediaServer.name],
async ([type, name]) => {
ensureUgreenScanMode()
console.log('Media server changed:', { type, name })
if (type && name) {
await loadLibrary(name)
@@ -141,6 +158,19 @@ watch(
</VCardText>
</VCard>
</VCol>
<VCol cols="12" md="3">
<VCard
:color="wizardData.mediaServer.type === 'ugreen' ? 'primary' : 'default'"
:variant="wizardData.mediaServer.type === 'ugreen' ? 'tonal' : 'outlined'"
class="cursor-pointer"
@click="selectMediaServerWithLibrary('ugreen')"
>
<VCardText class="text-center">
<VImg :src="getLogoUrl('ugreen')" height="48" width="48" class="mx-auto mb-2" />
<div class="text-h6">绿联影视</div>
</VCardText>
</VCard>
</VCol>
</VRow>
</div>
</VCol>
@@ -380,6 +410,97 @@ watch(
/>
</VCol>
</VRow>
<VRow v-else-if="wizardData.mediaServer.type === 'ugreen'">
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.mediaServer.name"
:label="t('common.name')"
:placeholder="t('mediaserver.nameRequired')"
:hint="t('mediaserver.serverAlias')"
:error="validationErrors.mediaServer.name"
:error-messages="validationErrors.mediaServer.name ? [t('mediaserver.nameRequired')] : []"
persistent-hint
active
prepend-inner-icon="mdi-label"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.mediaServer.config.host"
:label="t('mediaserver.host')"
:placeholder="t('mediaserver.hostPlaceholder')"
:hint="t('mediaserver.hostHint')"
:error="validationErrors.mediaServer.host"
:error-messages="validationErrors.mediaServer.host ? [t('mediaserver.hostRequired')] : []"
persistent-hint
active
prepend-inner-icon="mdi-server"
required
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="wizardData.mediaServer.config.play_host"
:label="t('mediaserver.playHost')"
:placeholder="t('mediaserver.playHostPlaceholder')"
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-play-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.mediaServer.config.username"
:label="t('mediaserver.username')"
:error="validationErrors.mediaServer.username"
:error-messages="validationErrors.mediaServer.username ? [t('mediaserver.usernameRequired')] : []"
active
prepend-inner-icon="mdi-account"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
type="password"
v-model="wizardData.mediaServer.config.password"
:label="t('mediaserver.password')"
:error="validationErrors.mediaServer.password"
:error-messages="validationErrors.mediaServer.password ? [t('mediaserver.passwordRequired')] : []"
active
prepend-inner-icon="mdi-lock"
required
/>
</VCol>
<VCol cols="12">
<VAutocomplete
v-model="wizardData.mediaServer.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
chips
multiple
clearable
:hint="t('mediaserver.syncLibrariesHint')"
persistent-hint
active
append-inner-icon="mdi-refresh"
prepend-inner-icon="mdi-library"
@click:append-inner="loadLibrary(wizardData.mediaServer.name)"
/>
</VCol>
<VCol cols="12" md="6">
<VSelect
v-model="wizardData.mediaServer.config.scan_mode"
:label="t('mediaserver.scanMode')"
:items="ugreenScanModeOptions"
:hint="t('mediaserver.scanModeHint')"
persistent-hint
active
prepend-inner-icon="mdi-radar"
/>
</VCol>
</VRow>
<VRow v-else-if="wizardData.mediaServer.type === 'plex'">
<VCol cols="12" md="6">
<VTextField

1003
yarn.lock

File diff suppressed because it is too large Load Diff