Compare commits

..

57 Commits

Author SHA1 Message Date
jxxghp
37b92c55ba feat:文件管理手动刮削 2024-02-10 19:32:49 +08:00
jxxghp
9299f1bcb6 release 2024-02-10 09:48:45 +08:00
jxxghp
7fe12192df add apexcharts 2024-02-10 09:36:54 +08:00
jxxghp
1169644ab3 fix render 2024-02-09 08:43:36 +08:00
jxxghp
6f7770ed43 fix 2024-02-08 20:58:41 +08:00
jxxghp
8059fd6f90 fix 2024-02-08 20:57:09 +08:00
jxxghp
556dbd8d78 fix bug 2024-02-08 20:07:23 +08:00
jxxghp
6695fd8c14 add ace-editor 2024-02-08 19:56:28 +08:00
jxxghp
3ab0229275 fix ui 2024-02-08 13:43:37 +08:00
jxxghp
99467127a0 更新 package.json 2024-02-08 13:28:11 +08:00
jxxghp
90d73b7bd5 Merge pull request #73 from cikezhu/main 2024-02-08 13:27:40 +08:00
叮叮当
2e326e1798 fix 全部日志url反向代理时被serviceWorker拦截 2024-02-08 12:45:52 +08:00
jxxghp
251eac93c7 更新 package.json 2024-02-08 07:11:49 +08:00
jxxghp
c74d70808c Merge pull request #72 from cikezhu/main 2024-02-08 07:10:48 +08:00
叮叮当
e63b2d7152 新窗口打开全部日志 2024-02-08 00:06:52 +08:00
jxxghp
16b29b56a5 更新 package.json 2024-01-29 23:11:57 +08:00
jxxghp
6d79c4fe2f Merge pull request #71 from cikezhu/main 2024-01-29 23:11:34 +08:00
叮叮当
4b1fb60ee3 fix 媒体信息页面person跳转问题 2024-01-29 23:01:22 +08:00
jxxghp
1d2be54f9e 更新 package.json 2024-01-29 11:05:38 +08:00
jxxghp
83547e32db Merge pull request #70 from cikezhu/main 2024-01-29 11:05:10 +08:00
叮叮当
70ddb929f2 fix plugin_icon 2024-01-28 00:47:37 +08:00
叮叮当
8b22961394 支持基于路径的反向代理 2024-01-27 14:47:00 +08:00
jxxghp
c15d42c179 Merge pull request #69 from falling/main 2024-01-24 18:43:37 +08:00
falling
098e473cab 从qbt后台的返回值来更新下载状态 2024-01-21 19:48:47 +08:00
jxxghp
f6f3d9368a fix bug 2024-01-08 13:22:42 +08:00
jxxghp
9558a420e9 fix image proxy 2024-01-08 12:25:06 +08:00
jxxghp
4d3b69ca34 更新 package.json 2024-01-08 11:33:32 +08:00
jxxghp
fdcc4a44c8 Merge pull request #68 from jjjokin/feat-auto-switch-theme 2024-01-08 11:01:26 +08:00
jokin
5de0494538 增加主题自适应选项 2024-01-07 22:17:11 +08:00
jxxghp
2045f833e4 fix bug 2024-01-06 11:07:23 +08:00
jxxghp
cc4f89aac1 fix 2024-01-06 11:02:03 +08:00
jxxghp
1c2f2c17d4 fix plex image 2024-01-06 10:58:35 +08:00
jxxghp
ace7a6621f image proxy 2024-01-05 21:35:33 +08:00
jxxghp
d02fe55a1e fix ui 2024-01-05 20:46:23 +08:00
jxxghp
9b753a8f5b fix 2024-01-05 20:40:44 +08:00
jxxghp
11e82582b8 fix 2024-01-05 17:34:52 +08:00
jxxghp
419358863e feat: dashboard 2024-01-05 17:32:30 +08:00
jxxghp
1d0d7f9975 dashboard cards 2024-01-05 15:59:03 +08:00
jxxghp
c5f564372b Merge pull request #66 from thofx/thofx_fix_ui 2024-01-04 21:38:30 +08:00
thofx
a50f0cd727 fix: 列表使用useDefer渲染 2024-01-04 21:35:35 +08:00
jxxghp
96f6f55138 fix type 2024-01-04 20:50:51 +08:00
jxxghp
6a45c8b358 fix service.js 2024-01-04 20:46:42 +08:00
jxxghp
165937596e fix safari window.open 2024-01-04 08:12:32 +08:00
jxxghp
fb976f043b fix 2024-01-03 21:30:00 +08:00
jxxghp
ecb9c4e51a fix safari 2024-01-03 21:29:40 +08:00
jxxghp
9e8c3b495c 更新 MediaDetailView.vue 2024-01-03 21:11:56 +08:00
jxxghp
24a37fc33c 更新 MediaDetailView.vue 2024-01-03 18:58:16 +08:00
jxxghp
d09a21114d fix 播放跳转 2024-01-03 18:39:39 +08:00
jxxghp
6e2b12501f feat:订阅弹窗开关 2024-01-03 18:11:13 +08:00
jxxghp
2a56e116cf fix 2024-01-03 17:30:49 +08:00
jxxghp
6de4f238d8 feat:size limit 2024-01-03 17:05:01 +08:00
jxxghp
1b426c5957 fix bug 2024-01-03 16:52:35 +08:00
jxxghp
82454a650c feat:dashboard可编辑 2024-01-03 13:09:28 +08:00
jxxghp
227b6bd7ef v1.5.7 2024-01-03 12:46:23 +08:00
jxxghp
9554025daf feat:在线播放 2024-01-03 12:45:44 +08:00
jxxghp
0eb5d607bf v1.5.6 2024-01-01 20:06:37 +08:00
jxxghp
750f4bc276 fix ui 2023-12-30 10:07:02 +08:00
43 changed files with 1294 additions and 188 deletions

View File

@@ -1 +1 @@
VITE_API_BASE_URL=/api/v1/
VITE_API_BASE_URL=api/v1/

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "1.5.5",
"version": "1.6.3",
"private": true,
"bin": "dist/service.js",
"scripts": {
@@ -24,6 +24,7 @@
"@floating-ui/dom": "1.2.8",
"@vueuse/core": "^10.1.2",
"@vueuse/math": "^10.1.2",
"ace-builds": "^1.32.6",
"apexcharts-clevision": "^3.28.5",
"axios": "1.4.0",
"axios-mock-adapter": "^1.21.4",
@@ -48,6 +49,7 @@
"vue-prism-component": "^2.0.0",
"vue-router": "^4.2.0",
"vue-toast-notification": "^3",
"vue3-ace-editor": "^2.2.4",
"vue3-apexcharts": "^1.4.1",
"vue3-perfect-scrollbar": "^1.6.0",
"vuetify": "3.3.5",
@@ -107,4 +109,4 @@
"resolutions": {
"postcss": "8"
}
}
}

View File

@@ -28,7 +28,12 @@ app.use(
// 处理根路径的请求
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'index.html')) // 指向你的前端入口文件
res.sendFile(path.join(__dirname, 'index.html'))
})
// 处理所有其他请求,重定向到前端入口文件
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'index.html'))
})
app.listen(port, () => {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref } from 'vue'
import { useTheme } from 'vuetify'
import type { ThemeSwitcherTheme } from '@layouts/types'
@@ -20,26 +20,30 @@ const {
{ initialValue: savedTheme.value },
)
function changeTheme() {
const nextTheme = getNextThemeName()
globalTheme.name.value = nextTheme
savedTheme.value = nextTheme
localStorage.setItem('theme', nextTheme)
function updateTheme() {
const autoTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
const theme = currentThemeName.value === 'auto' ? autoTheme : currentThemeName.value
globalTheme.name.value = theme
savedTheme.value = theme
// 修改载入时背景色
localStorage.setItem('materio-initial-loader-bg', globalTheme.current.value.colors.background)
themeTransition()
}
// Update icon if theme is changed from other sources
// 监听系统主题变化
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateTheme)
watch(
() => globalTheme.name.value,
(val) => {
currentThemeName.value = val
},
() => currentThemeName.value,
() => updateTheme(),
)
function changeTheme() {
const nextTheme = getNextThemeName()
currentThemeName.value = nextTheme
localStorage.setItem('theme', nextTheme)
}
// Apply saved theme on page load
// onMounted(() => {
// globalTheme.name.value = savedTheme.value

View File

@@ -109,3 +109,41 @@ export function formatBytes(bytes: number, decimals = 2) {
return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`
}
// 格式化剧集列表
export function formatEp(nums: number[]): string {
if (!nums.length)
return ''
if (nums.length === 1)
return nums[0].toString()
// 将数组升序排序
nums.sort((a, b) => a - b)
const formattedRanges: string[] = []
let start = nums[0]
let end = nums[0]
for (let i = 1; i < nums.length; i++) {
if (nums[i] === end + 1) {
end = nums[i]
}
else {
if (start === end)
formattedRanges.push(start.toString())
else
formattedRanges.push(`${start.toString()}-${end.toString()}`)
start = end = nums[i]
}
}
if (start === end)
formattedRanges.push(start.toString())
else
formattedRanges.push(`${start.toString()}-${end.toString()}`)
return formattedRanges.join('、')
}

View File

@@ -33,7 +33,7 @@ export function isToday(date: Date) {
)
}
// 计算时间差返回xx天xx小时xx分钟
// 计算时间差返回xx天/xx小时/xx分钟/xx秒
export function calculateTimeDifference(inputTime: string): string {
if (!inputTime)
return ''
@@ -64,6 +64,38 @@ export function calculateTimeDifference(inputTime: string): string {
}
}
// 计算时间差返回xx天xx小时xx分钟
export function calculateTimeDiff(inputTime: string): string {
if (!inputTime)
return ''
// 使用当前时区
const inputDate = new Date(inputTime)
const currentDate = new Date()
const timeDifference = currentDate.getTime() - inputDate.getTime()
const secondsDifference = Math.floor(timeDifference / 1000)
const days = Math.floor(secondsDifference / 86400)
const hours = Math.floor(secondsDifference % 86400 / 3600)
const minutes = Math.floor(secondsDifference % 86400 % 3600 / 60)
const secones = Math.floor(secondsDifference % 60)
if (days > 0)
return `${days}${hours}小时${minutes}分钟`
else if (hours > 0)
return `${hours}小时${minutes}分钟`
else if (minutes > 0)
return `${minutes}分钟`
else if (secones > 0)
return `${secones}`
return ''
}
// 判断一个数组subArray是不是在另一个数组mainArray中
export function isContained(subArray: any[], mainArray: any[]): boolean {
return subArray.every(element => mainArray.includes(element))

View File

@@ -3,9 +3,15 @@ import { useToast } from 'vue-toast-notification'
import { useTheme } from 'vuetify'
import store from './store'
function setTheme() {
const { global: globalTheme } = useTheme()
let theme = localStorage.getItem('theme') || 'light'
if (theme === 'auto')
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
globalTheme.name.value = theme
}
// 第一时间应用主题
const { global: globalTheme } = useTheme()
globalTheme.name.value = localStorage.getItem('theme') || 'light'
setTheme()
// 提示框
const $toast = useToast()

54
src/ace-config.ts Normal file
View File

@@ -0,0 +1,54 @@
import ace from 'ace-builds'
import modeJsonUrl from 'ace-builds/src-noconflict/mode-json?url'
import modeJavascriptUrl from 'ace-builds/src-noconflict/mode-javascript?url'
import modeHtmlUrl from 'ace-builds/src-noconflict/mode-html?url'
import modeYamlUrl from 'ace-builds/src-noconflict/mode-yaml?url'
import themeGithubUrl from 'ace-builds/src-noconflict/theme-github?url'
import themeChromeUrl from 'ace-builds/src-noconflict/theme-chrome?url'
import themeMonokaiUrl from 'ace-builds/src-noconflict/theme-monokai?url'
import workerBaseUrl from 'ace-builds/src-noconflict/worker-base?url'
import workerJsonUrl from 'ace-builds/src-noconflict/worker-json?url'
import workerJavascriptUrl from 'ace-builds/src-noconflict/worker-javascript?url'
import workerHtmlUrl from 'ace-builds/src-noconflict/worker-html?url'
import workerYamlUrl from 'ace-builds/src-noconflict/worker-yaml?url'
import snippetsHtmlUrl from 'ace-builds/src-noconflict/snippets/html?url'
import snippetsJsUrl from 'ace-builds/src-noconflict/snippets/javascript?url'
import snippetsYamlUrl from 'ace-builds/src-noconflict/snippets/yaml?url'
import snippetsJsonUrl from 'ace-builds/src-noconflict/snippets/json?url'
import 'ace-builds/src-noconflict/ext-language_tools'
ace.config.setModuleUrl('ace/mode/json', modeJsonUrl)
ace.config.setModuleUrl('ace/mode/javascript', modeJavascriptUrl)
ace.config.setModuleUrl('ace/mode/html', modeHtmlUrl)
ace.config.setModuleUrl('ace/mode/yaml', modeYamlUrl)
ace.config.setModuleUrl('ace/theme/github', themeGithubUrl)
ace.config.setModuleUrl('ace/theme/chrome', themeChromeUrl)
ace.config.setModuleUrl('ace/theme/monokai', themeMonokaiUrl)
ace.config.setModuleUrl('ace/mode/base', workerBaseUrl)
ace.config.setModuleUrl('ace/mode/json_worker', workerJsonUrl)
ace.config.setModuleUrl('ace/mode/javascript_worker', workerJavascriptUrl)
ace.config.setModuleUrl('ace/mode/html_worker', workerHtmlUrl)
ace.config.setModuleUrl('ace/mode/yaml_worker', workerYamlUrl)
ace.config.setModuleUrl('ace/snippets/html', snippetsHtmlUrl)
ace.config.setModuleUrl('ace/snippets/javascript', snippetsJsUrl)
ace.config.setModuleUrl('ace/snippets/javascript', snippetsYamlUrl)
ace.config.setModuleUrl('ace/snippets/json', snippetsJsonUrl)
ace.require('ace/ext/language_tools')

View File

@@ -651,6 +651,13 @@ export interface TorrentInfo {
// 促销描述
volume_factor: string
// 免费时间
freedate: string
// 剩余免费时间
freedate_diff: string
}
// 识别元数据
@@ -917,3 +924,26 @@ export interface FileItem {
children: FileItem[]
modify_time: number
}
// 媒体服务器播放条目
export interface MediaServerPlayItem {
id?: string | number
title: string
subtitle?: string
type?: string
image?: string
link?: string
percent?: number
}
// 媒体服务器媒体库
export interface MediaServerLibrary {
server: string
id?: string | number
name: string
path?: string
type?: string
image?: string
image_list?: string[]
link?: string
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@@ -0,0 +1,89 @@
<script lang="ts" setup>
import type { MediaServerPlayItem } from '@/api/types'
// 输入参数
const props = defineProps({
media: Object as PropType<MediaServerPlayItem>,
width: String,
height: String,
})
// 图片是否加载完成
const imageLoaded = ref(false)
// 图片加载完成响应
function imageLoadHandler() {
imageLoaded.value = true
}
// 跳转播放
function goPlay() {
if (props.media?.link)
window.open(props.media?.link, '_blank')
}
// 计算图片地址
const getImgUrl = computed(() => {
const image = props.media?.image || ''
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(image)}/0`
})
</script>
<template>
<VHover
v-bind="props"
:height="props.height"
:width="props.width"
>
<template #default="hover">
<VCard
v-bind="hover.props"
:height="props.height"
:width="props.width"
class="ring-gray-500"
:class="{
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
'ring-1': imageLoaded,
}"
@click="goPlay"
>
<template #image>
<VImg
:src="getImgUrl"
aspect-ratio="2/3"
cover
@load="imageLoadHandler"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
</div>
</template>
<VCardText
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
>
<h1 class="mb-1 text-white text-shadow font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ...">
{{ props.media?.title }}
</h1>
<span class="text-shadow">{{ props.media?.subtitle }}</span>
</VCardText>
</VImg>
</template>
<div class="w-full absolute bottom-0">
<VProgressLinear
v-if="props.media?.percent"
:model-value="props.media?.percent"
bg-color="success"
color="success"
/>
</div>
</VCard>
</template>
</VHover>
</template>
<style lang="scss">
.text-shadow{
text-shadow:1px 1px #777;
}
</style>

View File

@@ -23,6 +23,11 @@ function getSpeedText() {
// 下载状态
const isDownloading = ref(props.info?.state === 'downloading')
// 监听props.info?.state的变化
watch(() => props.info?.state, (newValue) => {
isDownloading.value = newValue === 'downloading';
});
// 图片是否加载完成
const imageLoaded = ref(false)

View File

@@ -0,0 +1,202 @@
<script lang="ts" setup>
import type { MediaServerLibrary } from '@/api/types'
import plex from '@images/misc/plex.png'
import emby from '@images/misc/emby.png'
import jellyfin from '@images/misc/jellyfin.png'
// 输入参数
const props = defineProps({
media: Object as PropType<MediaServerLibrary>,
width: String,
height: String,
})
// canvas
const canvasRef = ref<HTMLCanvasElement>()
// 图片地址
const imgUrl = ref('')
// 图片是否加载完成
const imageLoaded = ref(false)
// 图片是否加载错误
const imageError = ref(false)
// 图片加载完成响应
function imageLoadHandler() {
imageLoaded.value = true
}
// 图片加载错误
function imageErrorHandler() {
imageError.value = true
}
// 默认图片
function getDefaultImage() {
if (props.media?.server === 'plex')
return plex
else if (props.media?.server === 'emby')
return emby
else if (props.media?.server === 'jellyfin')
return jellyfin
else
return plex
}
// 跳转播放
function goPlay() {
if (props.media?.link)
window.open(props.media?.link, '_blank')
}
// 生成图片代理路径
function getImgUrl(url: string) {
if (!url)
return getDefaultImage()
else
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(url)}/0`
}
// 根据多张图片生成媒体库封面
async function drawImages(imageList: string[]) {
// 图片
const IMAGES = imageList
if (IMAGES.length === 0)
return getDefaultImage()
// 为所有图片添加system/img前缀
for (let i = 0; i < IMAGES.length; i++)
IMAGES[i] = `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(IMAGES[i])}/0`
// canvas
const canvas = canvasRef.value
if (!canvas)
return getDefaultImage()
// 画布参数
const POSTER_WIDTH = (canvas.width - 32) / 4
const POSTER_HEIGHT = canvas.height * 0.75 - 8
const MARGIN_WIDTH = 4
const MARGIN_HEIGHT = 4
const REFLECTION_HEIGHT = POSTER_HEIGHT / 2
const REFLECTION_SHOW_HEIGHT = canvas.height / 4
// 获取画布上下文
const ctx = canvas.getContext('2d')
if (!ctx)
return getDefaultImage()
// 设置背景色为黑色
ctx.fillStyle = '#000000'
ctx.fillRect(0, 0, canvas.width, canvas.height)
// 绘制图片
async function drawImageWithReflection(imgSrc: string, index: number) {
if (!canvas)
return
if (!ctx)
return
const img = new Image()
img.setAttribute('crossorigin', 'anonymous')
img.src = imgSrc
await new Promise(resolve => img.onload = resolve)
const x = MARGIN_WIDTH * index + POSTER_WIDTH * (index - 1)
const y = MARGIN_HEIGHT
ctx.drawImage(img, x, y, POSTER_WIDTH, POSTER_HEIGHT)
ctx.save()
ctx.translate(0, canvas.height)
ctx.scale(1, -1)
ctx.drawImage(
img,
0,
0,
img.width,
img.height,
x,
REFLECTION_SHOW_HEIGHT - REFLECTION_HEIGHT,
POSTER_WIDTH,
REFLECTION_HEIGHT,
)
const gradient = ctx.createLinearGradient(
0,
REFLECTION_SHOW_HEIGHT - REFLECTION_HEIGHT,
0,
REFLECTION_HEIGHT,
)
gradient.addColorStop(0, 'rgba(0, 0, 0, 1)')
gradient.addColorStop(1, 'rgba(0, 0, 0, 0.3)')
ctx.fillStyle = gradient
ctx.fillRect(x, 0, POSTER_WIDTH, REFLECTION_SHOW_HEIGHT)
ctx.restore()
}
// 绘制多张图片
const loopCount = Math.min(4, IMAGES.length)
for (let i = 0; i < loopCount; i++)
await drawImageWithReflection(IMAGES[i], i + 1)
// 转换为图片地址
return canvas.toDataURL('image/png')
}
onMounted(async () => {
if (props.media?.image_list && props.media?.image_list.length > 0)
imgUrl.value = await drawImages(props.media?.image_list || [])
else
imgUrl.value = getImgUrl(props.media?.image || '')
})
</script>
<template>
<VHover
v-bind="props"
:height="props.height"
:width="props.width"
>
<template #default="hover">
<VCard
v-bind="hover.props"
:height="props.height"
:width="props.width"
:class="{
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
}"
@click="goPlay"
>
<template #image>
<canvas ref="canvasRef" class="w-full h-full hidden" />
<VImg
:src="imgUrl"
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" />
</div>
</template>
<VCardText
class="w-full flex flex-col flex-wrap justify-end align-center text-white absolute bottom-0 cursor-pointer pa-2"
>
<h1 class="mb-1 text-white font-bold line-clamp-2 overflow-hidden text-ellipsis ...">
{{ props.media?.name }}
</h1>
</VCardText>
</VImg>
</template>
</VCard>
</template>
</VHover>
</template>

View File

@@ -16,6 +16,11 @@ const props = defineProps({
height: String,
})
// 订阅规则
const subscribeRules = ref({
show_edit_dialog: false,
})
// 提示框
const $toast = useToast()
@@ -146,7 +151,7 @@ async function addSubscribe(season = 0) {
)
// 弹出订阅编辑弹窗
if (result.success && seasonsSelected.value.length <= 1) {
if (result.success && seasonsSelected.value.length <= 1 && subscribeRules.value.show_edit_dialog) {
subscribeId.value = result.data.id
subscribeEditDialog.value = true
}
@@ -223,7 +228,7 @@ async function handleCheckSubscribe() {
// 查询当前媒体是否已入库
async function handleCheckExists() {
try {
const result: { [key: string]: any } = await api.get('media/exists', {
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
params: {
tmdbid: props.media?.tmdb_id,
title: props.media?.title,
@@ -269,7 +274,7 @@ async function checkSeasonsNotExists() {
// 开始处理
startNProgress()
try {
const result: NotExistMediaInfo[] = await api.post('download/notexists', props.media)
const result: NotExistMediaInfo[] = await api.post('mediaserver/notexists', props.media)
if (result) {
result.forEach((item) => {
// 0-已入库 1-部分缺失 2-全部缺失
@@ -302,6 +307,20 @@ async function getMediaSeasons() {
}
}
// 查询订阅弹窗规则
async function querySubscribeRules() {
try {
const result: { [key: string]: any } = await api.get(
'system/setting/DefaultFilterRules',
)
if (result.data?.value)
subscribeRules.value = result.data?.value
}
catch (error) {
console.log(error)
}
}
// 爱心订阅按钮响应
function handleSubscribe() {
if (isSubscribed.value)
@@ -373,6 +392,7 @@ function handleSearch() {
onBeforeMount(() => {
handleCheckSubscribe()
handleCheckExists()
querySubscribeRules()
})
// 计算图片地址

View File

@@ -85,9 +85,9 @@ const iconPath: Ref<string> = computed(() => {
return noImage
// 如果是网络图片则使用代理后返回
if (props.plugin?.plugin_icon?.startsWith('http'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(props.plugin?.plugin_icon)}`
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(props.plugin?.plugin_icon)}/1`
return `/plugin_icon/${props.plugin?.plugin_icon}`
return `./plugin_icon/${props.plugin?.plugin_icon}`
})
// 访问插件页面

View File

@@ -181,9 +181,9 @@ const iconPath: Ref<string> = computed(() => {
return noImage
// 如果是网络图片则使用代理后返回
if (props.plugin?.plugin_icon?.startsWith('http'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(props.plugin?.plugin_icon)}`
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(props.plugin?.plugin_icon)}/1`
return `/plugin_icon/${props.plugin?.plugin_icon}`
return `./plugin_icon/${props.plugin?.plugin_icon}`
})
// 重置插件

View File

@@ -0,0 +1,102 @@
<script lang="ts" setup>
import type { PropType } from 'vue'
import type { MediaServerPlayItem } from '@/api/types'
import noImage from '@images/no-image.jpeg'
// 输入参数
const props = defineProps({
media: Object as PropType<MediaServerPlayItem>,
width: String,
height: String,
})
// 图片加载状态
const isImageLoaded = ref(false)
// 图片加载失败
const imageLoadError = ref(false)
// 角标颜色
function getChipColor(type: string) {
if (type === '电影')
return 'border-blue-500 bg-blue-600'
else if (type === '电视剧')
return ' bg-indigo-500 border-indigo-600'
else
return 'border-purple-600 bg-purple-600'
}
// 计算图片地址
const getImgUrl = computed(() => {
if (imageLoadError.value)
return noImage
const image = props.media?.image || ''
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(image)}/0`
})
// 跳转播放
function goPlay() {
if (props.media?.link)
window.open(props.media?.link, '_blank')
}
</script>
<template>
<VHover v-bind="props">
<template #default="hover">
<VCard
v-bind="hover.props"
:height="props.height"
:width="props.width"
class="outline-none shadow ring-gray-500 rounded-lg"
:class="{
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
'ring-1': isImageLoaded,
}"
@click.stop="goPlay"
>
<VImg
aspect-ratio="2/3"
:src="getImgUrl"
class="object-cover aspect-w-2 aspect-h-3"
:class="hover.isHovering ? 'on-hover' : ''"
cover
@load="isImageLoaded = true"
@error="imageLoadError = true"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
<!-- 类型角标 -->
<VChip
v-show="isImageLoaded"
variant="elevated"
size="small"
:class="getChipColor(props.media?.type || '')"
class="absolute left-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
>
{{ props.media?.type }}
</VChip>
<!-- 详情 -->
<VCardText
v-show="hover.isHovering || imageLoadError"
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
>
<span class="font-bold">{{ props.media?.subtitle }}</span>
<h1 class="mb-1 text-white font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ...">
{{ props.media?.title }}
</h1>
</VCardText>
</VImg>
</VCard>
</template>
</VHover>
</template>
<style lang="scss">
.on-hover img {
@apply brightness-50;
}
</style>

View File

@@ -412,6 +412,23 @@ onMounted(() => {
<div class="text-sm my-1">
{{ item.raw.description }}
</div>
<VChip
v-if="item.raw?.hit_and_run"
variant="elevated"
size="small"
class="me-1 mb-1 text-white bg-black"
>
H&R
</VChip>
<VChip
v-if="item.raw?.freedate_diff"
variant="elevated"
color="secondary"
size="small"
class="me-1 mb-1"
>
{{ item.raw?.freedate_diff }}
</VChip>
<VChip
v-for="(label, index) in item.raw?.labels"
:key="index"

View File

@@ -195,6 +195,23 @@ onMounted(() => {
v-if="torrent?.labels"
class="pb-3 pt-0 pe-12"
>
<VChip
v-if="torrent?.hit_and_run"
variant="elevated"
size="small"
class="me-1 mb-1 text-white bg-black"
>
H&R
</VChip>
<VChip
v-if="torrent?.freedate_diff"
variant="elevated"
color="secondary"
size="small"
class="me-1 mb-1"
>
{{ torrent?.freedate_diff }}
</VChip>
<VChip
v-for="(label, index) in torrent?.labels"
:key="index"

View File

@@ -150,6 +150,23 @@ onMounted(() => {
v-if="torrent?.labels"
class="pt-2"
>
<VChip
v-if="torrent?.hit_and_run"
variant="elevated"
size="small"
class="me-1 mb-1 text-white bg-black"
>
H&R
</VChip>
<VChip
v-if="torrent?.freedate_diff"
variant="elevated"
color="secondary"
size="small"
class="me-1 mb-1"
>
{{ torrent?.freedate_diff }}
</VChip>
<VChip
v-for="(label, index) in torrent?.labels"
:key="index"

View File

@@ -267,6 +267,28 @@ async function recognize(path: string) {
}
}
// 调用API刮削
async function scrape(path: string) {
try {
// 显示进度条
progressDialog.value = true
progressText.value = `正在刮削 ${path} ...`
const result: { [key: string]: any } = await api.get('media/scrape', {
params: {
path,
},
})
// 关闭进度条
progressDialog.value = false
if (!result.success)
$toast.error(result.message)
else
$toast.success(`${path}削刮完成!`)
}
catch (error) {
console.error(error)
}
}
// 弹出菜单
const dropdownItems = ref([
{
@@ -279,8 +301,17 @@ const dropdownItems = ref([
},
},
}, {
title: '重命名',
title: '刮削',
value: 2,
props: {
prependIcon: 'mdi-auto-fix',
click: (_item: FileItem) => {
scrape(_item.path || '')
},
},
}, {
title: '重命名',
value: 3,
props: {
prependIcon: 'mdi-rename',
click: showRenmae,
@@ -288,7 +319,7 @@ const dropdownItems = ref([
},
{
title: '整理',
value: 3,
value: 4,
props: {
prependIcon: 'mdi-folder-arrow-right',
click: showTransfer,
@@ -296,7 +327,7 @@ const dropdownItems = ref([
},
{
title: '删除',
value: 4,
value: 5,
props: {
prependIcon: 'mdi-delete-outline',
color: 'error',
@@ -345,111 +376,133 @@ onMounted(() => {
<VCardText v-else-if="dirs.length || files.length" class="p-0">
<VList v-if="dirs.length" subheader>
<VListSubheader>目录</VListSubheader>
<VListItem
<VHover
v-for="(item, index) in dirs"
:key="index"
class="px-3 pe-1"
@click="changePath(item.path)"
>
<template #prepend>
<VIcon icon="mdi-folder-outline" />
</template>
<VListItemTitle v-text="item.name" />
<template #append>
<IconBtn class="d-sm-none">
<VIcon
icon="mdi-dots-vertical"
/>
<VMenu
activator="parent"
close-on-content-click
>
<VList>
<VListItem
v-for="(menu, i) in dropdownItems"
:key="i"
variant="plain"
:base-color="menu.props.color"
@click="menu.props.click(item)"
<template #default="hover">
<VListItem
v-bind="hover.props"
class="px-3 pe-1"
@click="changePath(item.path)"
>
<template #prepend>
<VIcon icon="mdi-folder-outline" />
</template>
<VListItemTitle v-text="item.name" />
<template #append>
<IconBtn class="d-sm-none">
<VIcon
icon="mdi-dots-vertical"
/>
<VMenu
activator="parent"
close-on-content-click
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="recognize(item.path)">
<VIcon icon="mdi-text-recognition" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="showRenmae(item)">
<VIcon icon="mdi-rename" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="showTransfer(item)">
<VIcon icon="mdi-folder-arrow-right" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="deleteItem(item)">
<VIcon icon="mdi-delete-outline" />
</IconBtn>
<VList>
<VListItem
v-for="(menu, i) in dropdownItems"
:key="i"
variant="plain"
:base-color="menu.props.color"
@click="menu.props.click(item)"
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
<span v-show="hover.isHovering" class="flex">
<IconBtn class="d-none d-sm-block" @click.stop="recognize(item.path)">
<VIcon icon="mdi-text-recognition" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="scrape(item.path)">
<VIcon icon="mdi-auto-fix" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="showRenmae(item)">
<VIcon icon="mdi-rename" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="showTransfer(item)">
<VIcon icon="mdi-folder-arrow-right" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="deleteItem(item)">
<VIcon icon="mdi-delete-outline" />
</IconBtn>
</span>
</template>
</VListItem>
</template>
</VListItem>
</VHover>
</VList>
<VDivider v-if="dirs.length && files.length" />
<VList v-if="files.length" subheader>
<VListSubheader>文件</VListSubheader>
<VListItem
<VHover
v-for="(item, index) in files"
:key="index"
class="pl-3 pe-1"
@click="changePath(item.path)"
>
<template #prepend>
<VIcon v-if="inProps.icons" :icon="inProps.icons[item.extension.toLowerCase()] || inProps.icons?.other" />
</template>
<template #default="hover">
<VListItem
v-bind="hover.props"
class="pl-3 pe-1"
@click="changePath(item.path)"
>
<template #prepend>
<VIcon v-if="inProps.icons" :icon="inProps.icons[item.extension.toLowerCase()] || inProps.icons?.other" />
</template>
<VListItemTitle v-text="item.name" />
<VListItemSubtitle> {{ formatBytes(item.size) }}</VListItemSubtitle>
<VListItemTitle v-text="item.name" />
<VListItemSubtitle> {{ formatBytes(item.size) }}</VListItemSubtitle>
<template #append>
<IconBtn class="d-sm-none">
<VIcon
icon="mdi-dots-vertical"
/>
<VMenu
activator="parent"
close-on-content-click
>
<VList>
<VListItem
v-for="(menu, i) in dropdownItems"
:key="i"
variant="plain"
:base-color="menu.props.color"
@click="menu.props.click(item)"
<template #append>
<IconBtn class="d-sm-none">
<VIcon
icon="mdi-dots-vertical"
/>
<VMenu
activator="parent"
close-on-content-click
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="recognize(item.path)">
<VIcon icon="mdi-text-recognition" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="showRenmae(item)">
<VIcon icon="mdi-rename" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="showTransfer(item)">
<VIcon icon="mdi-folder-arrow-right" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="deleteItem(item)">
<VIcon icon="mdi-delete-outline" />
</IconBtn>
<VList>
<VListItem
v-for="(menu, i) in dropdownItems"
:key="i"
variant="plain"
:base-color="menu.props.color"
@click="menu.props.click(item)"
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
<span v-show="hover.isHovering" class="flex">
<IconBtn class="d-none d-sm-block" @click.stop="recognize(item.path)">
<VIcon icon="mdi-text-recognition" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="scrape(item.path)">
<VIcon icon="mdi-auto-fix" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="showRenmae(item)">
<VIcon icon="mdi-rename" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="showTransfer(item)">
<VIcon icon="mdi-folder-arrow-right" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="deleteItem(item)">
<VIcon icon="mdi-delete-outline" />
</IconBtn>
</span>
</template>
</VListItem>
</template>
</VListItem>
</VHover>
</VList>
</VCardText>
<VCardText

View File

@@ -326,7 +326,6 @@ watchEffect(() => {
<VRow>
<VCol
cols="12"
md="4"
>
<VTextField
v-model="subscribeForm.save_path"

View File

@@ -32,23 +32,58 @@ const formData = ref<any>(elementProps.form || {})
<template>
<Component
:is="formItem.component"
v-if="!formItem.html"
v-if="!formItem.html && !!formItem.props?.modelvalue"
v-bind="formItem.props"
v-model="formData[formItem.props?.model || '']"
v-model:value="formData[formItem.props?.modelvalue]"
>
{{ formItem.text }}
<FormRender
<template
v-for="(innerItem, innerIndex) in (formItem.content || [])"
:key="innerIndex"
v-model="formData[innerItem.props?.model || '']"
:config="innerItem"
:form="formData"
/>
>
<FormRender
v-if="!!innerItem.props?.modelvalue"
v-model:value="formData[innerItem.props?.modelvalue]"
:config="innerItem"
:form="formData"
/>
<FormRender
v-else
v-model="formData[innerItem.props?.model]"
:config="innerItem"
:form="formData"
/>
</template>
</Component>
<Component
:is="formItem.component"
v-if="formItem.html"
v-else-if="formItem.html"
v-bind="formItem.props"
v-html="formItem.html"
/>
<Component
:is="formItem.component"
v-else
v-bind="formItem.props"
v-model="formData[formItem.props?.model]"
>
{{ formItem.text }}
<template
v-for="(innerItem, innerIndex) in (formItem.content || [])"
:key="innerIndex"
>
<FormRender
v-if="!!innerItem.props?.modelvalue"
v-model:value="formData[innerItem.props?.modelvalue]"
:config="innerItem"
:form="formData"
/>
<FormRender
v-else
v-model="formData[innerItem.props?.model]"
:config="innerItem"
:form="formData"
/>
</template>
</Component>
</template>

View File

@@ -14,6 +14,10 @@ const themes: ThemeSwitcherTheme[] = [
name: 'purple',
icon: 'mdi-brightness-4',
},
{
name: 'auto',
icon: 'mdi-brightness-auto',
},
]
</script>

View File

@@ -3,6 +3,7 @@ import NameTestView from '@/views/system/NameTestView.vue'
import NetTestView from '@/views/system/NetTestView.vue'
import LoggingView from '@/views/system/LoggingView.vue'
import RuleTestView from '@/views/system/RuleTestView.vue'
import store from '@/store'
// App捷径
const appsMenu = ref(false)
@@ -18,6 +19,12 @@ const loggingDialog = ref(false)
// 过滤规则弹窗
const ruleTestDialog = ref(false)
// 拼接全部日志url
function allLoggingUrl() {
const token = store.state.auth.token
return `${import.meta.env.VITE_API_BASE_URL}system/logging?token=${token}&length=-1`
}
</script>
<template>
@@ -171,8 +178,19 @@ const ruleTestDialog = ref(false)
class="w-full lg:w-4/5"
scrollable
>
<VCard title="实时日志">
<VCard>
<DialogCloseBtn @click="loggingDialog = false" />
<VCardItem>
<VCardTitle class="inline-flex">
实时日志
<a class="mx-2 inline-flex items-center justify-center" :href="allLoggingUrl()" target="_blank">
<div class="inline-flex cursor-pointer items-center rounded-full bg-gray-600 px-2 text-sm text-gray-200 ring-1 ring-gray-500 transition hover:bg-gray-700">
<VIcon icon="mdi-open-in-new" />
<span class="ms-1">在新窗口中打开</span>
</div>
</a>
</VCardTitle>
</VCardItem>
<VCardText>
<LoggingView />
</VCardText>

View File

@@ -1,7 +1,11 @@
import { VAceEditor } from 'vue3-ace-editor'
import { createApp } from 'vue'
import '@/@iconify/icons-bundle'
import ToastPlugin from 'vue-toast-notification'
import VuetifyUseDialog from 'vuetify-use-dialog'
import './ace-config'
import VueApexCharts from 'vue3-apexcharts'
import { removeEl } from './@core/utils/dom'
import App from '@/App.vue'
import vuetify from '@/plugins/vuetify'
import { loadFonts } from '@/plugins/webfontloader'
@@ -11,14 +15,17 @@ import '@core/scss/template/index.scss'
import '@layouts/styles/index.scss'
import '@styles/styles.scss'
import 'vue-toast-notification/dist/theme-bootstrap.css'
import { removeEl } from '@/util'
loadFonts()
// Create vue app
// 创建Vue实例
const app = createApp(App)
// Use plugins Mount vue app
// 注册全局组件
app.component('VAceEditor', VAceEditor)
.component('VApexChart', VueApexCharts)
// 注册插件
app
.use(vuetify)
.use(router)

View File

@@ -6,11 +6,57 @@ import AnalyticsStorage from '@/views/dashboard/AnalyticsStorage.vue'
import AnalyticsWeeklyOverview from '@/views/dashboard/AnalyticsWeeklyOverview.vue'
import AnalyticsCpu from '@/views/dashboard/AnalyticsCpu.vue'
import AnalyticsMemory from '@/views/dashboard/AnalyticsMemory.vue'
import MediaServerLatest from '@/views/dashboard/MediaServerLatest.vue'
import MediaServerLibrary from '@/views/dashboard/MediaServerLibrary.vue'
import MediaServerPlaying from '@/views/dashboard/MediaServerPlaying.vue'
// 仪表盘配置
const dashboard_names = {
storage: '存储空间',
mediaStatistic: '媒体统计',
weeklyOverview: '最近入库',
speed: '实时速率',
scheduler: '后台任务',
cpu: 'CPU',
memory: '内存',
library: '我的媒体库',
playing: '继续观看',
latest: '最近添加',
}
// 弹窗
const dialog = ref(false)
// 从localStorage中获取数据
const default_config = {
mediaStatistic: true,
scheduler: false,
speed: false,
storage: true,
weeklyOverview: false,
cpu: false,
memory: false,
library: true,
playing: true,
latest: true,
}
const config = ref(JSON.parse(localStorage.getItem('MP_DASHBOARD') || '{}'))
if (Object.keys(config.value).length === 0) {
config.value = default_config
localStorage.setItem('MP_DASHBOARD', JSON.stringify(config.value))
}
// 设置项目
function setDashboardConfig() {
localStorage.setItem('MP_DASHBOARD', JSON.stringify(config.value))
dialog.value = false
}
</script>
<template>
<VRow class="match-height">
<VCol
v-if="config.storage"
cols="12"
md="4"
>
@@ -18,6 +64,7 @@ import AnalyticsMemory from '@/views/dashboard/AnalyticsMemory.vue'
</VCol>
<VCol
v-if="config.mediaStatistic"
cols="12"
md="8"
>
@@ -25,6 +72,7 @@ import AnalyticsMemory from '@/views/dashboard/AnalyticsMemory.vue'
</VCol>
<VCol
v-if="config.weeklyOverview"
cols="12"
md="4"
>
@@ -32,6 +80,7 @@ import AnalyticsMemory from '@/views/dashboard/AnalyticsMemory.vue'
</VCol>
<VCol
v-if="config.speed"
cols="12"
md="4"
>
@@ -39,6 +88,7 @@ import AnalyticsMemory from '@/views/dashboard/AnalyticsMemory.vue'
</VCol>
<VCol
v-if="config.scheduler"
cols="12"
md="4"
>
@@ -46,6 +96,7 @@ import AnalyticsMemory from '@/views/dashboard/AnalyticsMemory.vue'
</VCol>
<VCol
v-if="config.cpu"
cols="12"
md="6"
>
@@ -53,10 +104,75 @@ import AnalyticsMemory from '@/views/dashboard/AnalyticsMemory.vue'
</VCol>
<VCol
v-if="config.memory"
cols="12"
md="6"
>
<AnalyticsMemory />
</VCol>
<VCol
v-if="config.library"
cols="12"
>
<MediaServerLibrary />
</VCol>
<VCol
v-if="config.playing"
cols="12"
>
<MediaServerPlaying />
</VCol>
<VCol
v-if="config.latest"
cols="12"
>
<MediaServerLatest />
</VCol>
</VRow>
<!-- 底部操作按钮 -->
<span class="fixed right-5 bottom-5">
<VBtn icon="mdi-view-dashboard-edit" class="me-2" color="primary" size="x-large" @click="dialog = true" />
</span>
<!-- 弹窗根据配置生成选项 -->
<VDialog
v-model="dialog"
max-width="600"
scrollable
>
<VCard title="设置仪表板">
<VCardText>
<VRow>
<VCol
v-for="(item, key) in dashboard_names"
:key="key"
cols="12"
md="4"
>
<VCheckbox
v-model="config[key]"
:label="dashboard_names[key]"
/>
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VBtn
color="primary"
@click="dialog = false"
>
取消
</VBtn>
<VSpacer />
<VBtn
color="primary"
@click="setDashboardConfig"
>
保存
</VBtn>
</VCardActions>
</VCard>
</vdialog>
</template>

View File

@@ -1,4 +1,4 @@
import { createRouter, createWebHistory } from 'vue-router'
import { createRouter, createWebHashHistory } from 'vue-router'
import { configureNProgress, doneNProgress, startNProgress } from '@/api/nprogress'
import store from '@/store'
@@ -7,7 +7,7 @@ configureNProgress()
// Router
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
history: createWebHashHistory(import.meta.env.BASE_URL),
scrollBehavior(to, from, savedPosition) {
// 如果页面有缓存那么恢复其位置, 否则始终滚动到顶部
if (to.meta.keepAlive && savedPosition)

View File

@@ -3,10 +3,6 @@
@tailwind components;
@tailwind utilities;
:root{
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei UI", "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
}
#nprogress .bar {
background: rgb(var(--v-theme-primary)) !important;
top: env(safe-area-inset-top) !important;

View File

@@ -1 +0,0 @@
export * from './dom'

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import api from '@/api'
import type { MediaServerPlayItem } from '@/api/types'
import PosterCard from '@/components/cards/PosterCard.vue'
// 最近入库列表
const latestList = ref<MediaServerPlayItem[]>([])
// 调用API查询
async function loadLatest() {
try {
latestList.value = await api.get('mediaserver/latest')
}
catch (e) {
console.log(e)
}
}
onMounted(() => {
loadLatest()
})
</script>
<template>
<VCard>
<VCardItem>
<VCardTitle>最近添加</VCardTitle>
</VCardItem>
<div
v-if="latestList.length > 0"
class="grid gap-4 grid-media-card mx-3 mb-3"
tabindex="0"
>
<PosterCard
v-for="data in latestList"
:key="data.id"
:media="data"
/>
</div>
</VCard>
</template>
<style lang="scss">
.grid-media-card {
grid-template-columns: repeat(auto-fill, minmax(9.375rem, 1fr));
}
</style>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import api from '@/api'
import type { MediaServerPlayItem } from '@/api/types'
import LibraryCard from '@/components/cards/LibraryCard.vue'
// 媒体库列表
const libraryList = ref<MediaServerPlayItem[]>([])
// 调用API查询
async function loadLibrary() {
try {
libraryList.value = await api.get('mediaserver/library')
}
catch (e) {
console.log(e)
}
}
onMounted(() => {
loadLibrary()
})
</script>
<template>
<VCard>
<VCardItem>
<VCardTitle>我的媒体库</VCardTitle>
</VCardItem>
<div
v-if="libraryList.length > 0"
class="grid gap-4 grid-backdrop-card mx-3"
tabindex="0"
>
<LibraryCard
v-for="data in libraryList"
:key="data.id"
:media="data"
height="10rem"
/>
</div>
</VCard>
</template>
<style lang="scss">
.grid-backdrop-card {
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
padding-block-end: 1rem;
}
</style>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import api from '@/api'
import type { MediaServerPlayItem } from '@/api/types'
import BackdropCard from '@/components/cards/BackdropCard.vue'
// 继续播放列表
const playingList = ref<MediaServerPlayItem[]>([])
// 调用API查询
async function loadPlayingList() {
try {
playingList.value = await api.get('mediaserver/playing')
}
catch (e) {
console.log(e)
}
}
onMounted(() => {
loadPlayingList()
})
</script>
<template>
<VCard>
<VCardItem>
<VCardTitle>继续观看</VCardTitle>
</VCardItem>
<div
v-if="playingList.length > 0"
class="grid gap-4 grid-backdrop-card mx-3"
tabindex="0"
>
<BackdropCard
v-for="data in playingList"
:key="data.id"
:media="data"
height="10rem"
/>
</div>
</VCard>
</template>
<style lang="scss">
.grid-backdrop-card {
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
padding-block-end: 1rem;
}
</style>

View File

@@ -25,8 +25,8 @@ const mediaDetail = ref<MediaInfo>({} as MediaInfo)
// 订阅编辑弹窗
const subscribeEditDialog = ref(false)
// 本地是否存在
const isExists = ref(false)
// 本地是否存在存在则包括Item信息
const existsItemId = ref('')
// 是否已订阅
const isSubscribed = ref(false)
@@ -46,6 +46,11 @@ const seasonsSubscribed = ref<{ [key: number]: boolean }>({})
// 订阅编号
const subscribeId = ref<number>()
// 订阅规则
const subscribeRules = ref({
show_edit_dialog: false,
})
// 调用API查询详情
async function getMediaDetail() {
if (mediaProps.mediaid && mediaProps.type) {
@@ -59,9 +64,8 @@ async function getMediaDetail() {
return
// 检查存在状态
if (mediaDetail.value.type === '电影')
checkMovieExists()
else
checkExists()
if (mediaDetail.value.type === '电视剧')
checkSeasonsNotExists()
// 检查订阅状态
if (mediaDetail.value.type === '电影')
@@ -86,9 +90,9 @@ async function loadSeasonEpisodes(season: number) {
}
// 查询当前媒体是否已入库
async function checkMovieExists() {
async function checkExists() {
try {
const result: { [key: string]: any } = await api.get('media/exists', {
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
params: {
tmdbid: mediaDetail.value.tmdb_id,
title: mediaDetail.value.title,
@@ -99,7 +103,7 @@ async function checkMovieExists() {
})
if (result.success)
isExists.value = true
existsItemId.value = result.data.item.id
}
catch (error) {
console.error(error)
@@ -133,11 +137,8 @@ async function checkSeasonsNotExists() {
if (mediaDetail.value.type !== '电视剧')
return
try {
const result: NotExistMediaInfo[] = await api.post('download/notexists', mediaDetail.value)
const result: NotExistMediaInfo[] = await api.post('mediaserver/notexists', mediaDetail.value)
if (result) {
if (result.length === 0)
isExists.value = true
result.forEach((item) => {
// 0-已入库 1-部分缺失 2-全部缺失
let state = 0
@@ -145,8 +146,6 @@ async function checkSeasonsNotExists() {
state = 2
else if (item.episodes.length < item.total_episode)
state = 1
if (state !== 2)
isExists.value = true
seasonsNotExisted.value[item.season] = state
})
}
@@ -188,7 +187,7 @@ async function addSubscribe(season = 0) {
startNProgress()
try {
// 是否洗版
let best_version = isExists.value ? 1 : 0
let best_version = existsItemId.value ? 1 : 0
if (season)
// 全部存在时洗版
best_version = !seasonsNotExisted.value[season] ? 1 : 0
@@ -221,7 +220,7 @@ async function addSubscribe(season = 0) {
)
// 显示编辑弹窗
if (result.success) {
if (result.success && subscribeRules.value.show_edit_dialog) {
subscribeId.value = result.data.id
subscribeEditDialog.value = true
}
@@ -283,6 +282,20 @@ async function removeSubscribe(season: number) {
doneNProgress()
}
// 查询订阅弹窗规则
async function querySubscribeRules() {
try {
const result: { [key: string]: any } = await api.get(
'system/setting/DefaultFilterRules',
)
if (result.data?.value)
subscribeRules.value = result.data?.value
}
catch (error) {
console.log(error)
}
}
// 订阅按钮响应
function handleSubscribe(season = 0) {
if (isSubscribed.value)
@@ -403,8 +416,29 @@ function handleSearch(area: string) {
})
}
// 跳转播放页面
async function handlePlay() {
// 获取播放链接地址
try {
const result: { [key: string]: any } = await api.get(
`mediaserver/play/${existsItemId.value}`,
)
if (result?.success) {
// 打开链接地址
setTimeout(() => {
window.open(result.data.url, '_blank')
}, 100)
}
else { $toast.error(`获取播放链接失败:${result.message}`) }
}
catch (error) {
console.error(error)
}
}
onBeforeMount(() => {
getMediaDetail()
querySubscribeRules()
})
</script>
@@ -438,7 +472,7 @@ onBeforeMount(() => {
</VImg>
</div>
<div class="media-title">
<div v-if="isExists" class="media-status">
<div v-if="existsItemId" class="media-status">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap transition !no-underline bg-green-500 bg-opacity-80 border border-green-500 !text-green-100 hover:bg-green-500 hover:bg-opacity-100 false overflow-hidden">
<div class="relative z-20 flex items-center false"><span>已入库</span></div>
</span>
@@ -458,7 +492,7 @@ onBeforeMount(() => {
</span>
</div>
<div class="media-actions">
<VBtn v-if="mediaDetail.tmdb_id || mediaDetail.douban_id" variant="tonal" color="info">
<VBtn v-if="mediaDetail.tmdb_id || mediaDetail.douban_id" variant="tonal" color="info" class="mb-2">
<template #prepend>
<VIcon icon="mdi-magnify" />
</template>
@@ -484,12 +518,18 @@ onBeforeMount(() => {
</VList>
</VMenu>
</VBtn>
<VBtn v-if="mediaDetail.type === '电影' || mediaDetail.douban_id" class="ms-2" :color="getSubscribeColor" variant="tonal" @click="handleSubscribe(0)">
<VBtn v-if="mediaDetail.type === '电影' || mediaDetail.douban_id" class="ms-2 mb-2" :color="getSubscribeColor" variant="tonal" @click="handleSubscribe(0)">
<template #prepend>
<VIcon :icon="getSubscribeIcon" />
</template>
{{ isSubscribed ? '已订阅' : '订阅' }}
</VBtn>
<VBtn v-if="existsItemId" class="ms-2 mb-2" variant="tonal" @click="handlePlay()">
<template #prepend>
<VIcon icon="mdi-play" />
</template>
在线播放
</VBtn>
</div>
</div>
<div class="media-overview">
@@ -504,7 +544,9 @@ onBeforeMount(() => {
<ul v-if="mediaDetail.tmdb_id" class="media-crew">
<li v-for="director in mediaDetail.directors" :key="director.id">
<span>{{ director.job }}</span>
<a class="crew-name" :href="`person?personid=${director.id}`" target="_blank">{{ director.name }}</a>
<RouterLink :to="`/person?personid=${director.id}`" class="crew-name" target="_blank">
{{ director.name }}
</RouterLink>
</li>
</ul>
<ul v-if="!mediaDetail.tmdb_id && mediaDetail.douban_id" class="media-crew">

View File

@@ -1,10 +1,8 @@
<script lang="ts" setup>
import _ from 'lodash'
import type { Ref } from 'vue'
import { ref } from 'vue'
import { useDefer } from '@/util'
import type { Context } from '@/api/types'
import TorrentCard from '@/components/cards/TorrentCard.vue'
import { useDefer } from '@/@core/utils/dom'
interface SearchTorrent extends Context {
more?: Array<Context>
@@ -94,7 +92,7 @@ onMounted(() => {
groupedDataList.value = groupMap
})
const defer: Ref<Function> = ref(() => true)
let defer = (_: number) => true
// 计算过滤后的列表
watchEffect(() => {
@@ -135,7 +133,7 @@ watchEffect(() => {
}
}
})
defer.value = useDefer(dataList.value.length)
defer = useDefer(dataList.value.length)
})
</script>

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { ref } from 'vue'
import type { Context } from '@/api/types'
import TorrentItem from '@/components/cards/TorrentItem.vue'
import { useDefer } from '@/@core/utils/dom'
// 定义输入参数
const props = defineProps({
@@ -60,6 +60,8 @@ function initOptions(data: Context) {
optionValue(resolutionFilterOptions.value, meta_info?.resource_pix)
}
let defer = (_: number) => true
// 计算过滤后的列表
watchEffect(() => {
// 清空列表
@@ -88,6 +90,7 @@ watchEffect(() => {
)
dataList.value.push(data)
})
defer = useDefer(dataList.value.length)
})
// 初始化过滤选项
@@ -106,14 +109,14 @@ onMounted(() => {
<VListItemTitle>没有附合当前过滤条件的资源</VListItemTitle>
</VListItem>
</VList>
<v-virtual-scroll lines="three" class="rounded" :items="dataList" height="calc(100vh - 156px)">
<template #default="{ item }">
<TorrentItem :torrent="item" />
</template>
</v-virtual-scroll>
<div>
<div v-for="(item, index) in dataList" :key="`${index}_${item.torrent_info.title}_${item.torrent_info.site}`">
<TorrentItem v-if="defer(index)" :torrent="item" />
</div>
</div>
</VCol>
<VCol xl="2" md="3" class="d-none d-md-block">
<VList lines="one" class="rounded" height="calc(100vh - 156px)">
<VList lines="one" class="rounded">
<VListSubheader v-if="siteFilterOptions.length > 0">
站点
</VListSubheader>

View File

@@ -36,6 +36,9 @@ const currentRuleType = ref('SubscribeFilterRules')
const defaultFilterRules = ref({
include: '',
exclude: '',
movie_size: '',
tv_size: '',
show_edit_dialog: false,
})
// 导入代码弹窗
@@ -503,6 +506,28 @@ onMounted(() => {
label="排除(关键字、正则式)"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="defaultFilterRules.movie_size"
type="text"
label="电影文件大小GB"
placeholder="0-30"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="defaultFilterRules.tv_size"
type="text"
label="剧集单集文件大小GB"
placeholder="0-10"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="defaultFilterRules.show_edit_dialog"
label="订阅时编辑更多规则"
/>
</VCol>
</VRow>
</VForm>
</VCardText>

View File

@@ -5,9 +5,9 @@ import interactionPlugin from '@fullcalendar/interaction'
import timeGridPlugin from '@fullcalendar/timegrid'
import FullCalendar from '@fullcalendar/vue3'
import type { Ref } from 'vue'
import type { MediaInfo, Rss, Subscribe, TmdbEpisode } from '@/api/types'
import type { MediaInfo, Subscribe, TmdbEpisode } from '@/api/types'
import api from '@/api'
import { parseDate } from '@/@core/utils/formatters'
import { formatEp, parseDate } from '@/@core/utils/formatters'
// 日历属性
const calendarOptions: Ref<CalendarOptions> = ref({
@@ -33,7 +33,7 @@ const calendarOptions: Ref<CalendarOptions> = ref({
events: [],
})
async function eventsHander(subscribe: Subscribe | Rss) {
async function eventsHander(subscribe: Subscribe) {
// 如果是电影直接返回
if (subscribe.type === '电影') {
// 调用API查询TMDB详情
@@ -62,7 +62,7 @@ async function eventsHander(subscribe: Subscribe | Rss) {
subtitle: string
start: Date | null
allDay: boolean
posterPath: string
posterPath: string | undefined
mediaType: string
len: number
}
@@ -81,7 +81,7 @@ async function eventsHander(subscribe: Subscribe | Rss) {
else {
dictEpisode[air_date] = {
title: subscribe.name,
subtitle: `${episode.episode_number}`,
subtitle: `${episode.episode_number}`,
start: parseDate(episode.air_date || ''),
allDay: false,
posterPath: subscribe.poster,
@@ -90,10 +90,8 @@ async function eventsHander(subscribe: Subscribe | Rss) {
}
}
})
for (const key in dictEpisode) {
if (dictEpisode.hasOwnProperty(key))
dictEpisode[key].subtitle += '集'
}
for (const key in dictEpisode)
dictEpisode[key].subtitle = formatEp(dictEpisode[key].subtitle.split(',').map(Number))
return Object.values(dictEpisode)
}
@@ -148,11 +146,8 @@ onMounted(() => {
<VCardSubtitle class="pa-1 px-2 font-bold break-words whitespace-break-spaces">
{{ arg.event.title }}
</VCardSubtitle>
<VCardText class="pa-0 px-2">
{{ arg.event.extendedProps.len }}
</VCardText>
<VCardText class="pa-0 px-2 break-words">
{{ arg.event.extendedProps.subtitle }}
<VCardText v-if="arg.event.extendedProps.subtitle" class="pa-0 px-2 break-words">
{{ arg.event.extendedProps.subtitle }}
</VCardText>
</div>
</div>
@@ -178,8 +173,9 @@ onMounted(() => {
<VChip
v-if="arg.event.extendedProps.len > 1"
variant="elevated"
size="mini"
class="absolute right-0.5 top-0.5 bg-opacity-80 shadow-md text-white font-bold border-purple-600 bg-purple-600"
color="primary"
size="x-small"
class="absolute right-0 top-0"
>
{{ arg.event.extendedProps.len }}
</VChip>

View File

@@ -9,6 +9,7 @@ import vuetify from 'vite-plugin-vuetify'
// https://vitejs.dev/config/
export default defineConfig({
base: './',
plugins: [
vue(),
vueJsx(),
@@ -27,7 +28,16 @@ export default defineConfig({
imports: ['vue', 'vue-router', '@vueuse/core', '@vueuse/math', 'vuex'],
vueTemplate: true,
}),
VitePWA({ registerType: 'autoUpdate', injectRegister: 'script', manifest: false }),
VitePWA({
registerType: 'autoUpdate',
injectRegister: 'script',
manifest: false,
workbox: {
navigateFallbackDenylist: [
/.*\/api\/v\d+\/system\/logging.*/,
],
},
}),
],
define: { 'process.env': {} },
resolve: {

View File

@@ -2422,6 +2422,11 @@ accepts@~1.3.8:
mime-types "~2.1.34"
negotiator "0.6.3"
ace-builds@^1.32.6:
version "1.32.6"
resolved "https://registry.yarnpkg.com/ace-builds/-/ace-builds-1.32.6.tgz#454ec8bc9235fbb960b8d8b86e698f941c104de2"
integrity sha512-dO5BnyDOhCnznhOpILzXq4jqkbhRXxNkf3BuVTmyxGyRLrhddfdyk6xXgy+7A8LENrcYoFi/sIxMuH3qjNUN4w==
acorn-jsx@^5.2.0, acorn-jsx@^5.3.2:
version "5.3.2"
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
@@ -6651,6 +6656,11 @@ require-from-string@^2.0.2:
resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909"
integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==
resize-observer-polyfill@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464"
integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==
resolve-from@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
@@ -7912,6 +7922,13 @@ vue-tsc@^1.6.5:
"@volar/vue-typescript" "1.6.5"
semver "^7.3.8"
vue3-ace-editor@^2.2.4:
version "2.2.4"
resolved "https://registry.yarnpkg.com/vue3-ace-editor/-/vue3-ace-editor-2.2.4.tgz#1f2a787f91cf7979f27fab29e0e0604bb3ee1c17"
integrity sha512-FZkEyfpbH068BwjhMyNROxfEI8135Sc+x8ouxkMdCNkuj/Tuw83VP/gStFQqZHqljyX9/VfMTCdTqtOnJZGN8g==
dependencies:
resize-observer-polyfill "^1.5.1"
vue3-apexcharts@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/vue3-apexcharts/-/vue3-apexcharts-1.4.1.tgz#ea561308430a1c5213b7f17c44ba3c845f6c490d"