Browse Source

feat(frontend): 连续剧页面添加本地字幕上传

Signed-off-by: Myon <[email protected]>
Myon 3 years ago
parent
commit
722430eb86

+ 17 - 0
frontend/src/api/LibraryApi.js

@@ -16,5 +16,22 @@ class LibraryApi extends BaseApi {
   getSkipInfo = (data) => this.http(`/v1/video/list/scan_skip_info`, data, 'POST');
 
   setSkipInfo = (data) => this.http(`/v1/video/list/scan_skip_info`, data, 'PUT');
+
+  getMovieDetail = (data) => this.http(`/v1/video/list/one_movie_subs`, data, 'POST');
+
+  getTvDetail = (data) => this.http(`/v1/video/list/one_series_subs`, data, 'POST');
+
+  getSubtitleQueueStatus = (data) => this.http(`/v1/subtitles/is_manual_upload_2_local_in_queue`, data, 'POST');
+
+  uploadSubtitle = (data) =>
+    this.http(`/v1/subtitles/manual_upload_2_local`, data, 'POST', {
+      headers: {
+        'Content-Type': 'multipart/form-data',
+      },
+    });
+
+  refreshMediaServerSubList = () => this.http(`/v1/subtitles/refresh_media_server_sub_list`, {}, 'POST');
+
+  getSubTitleQueueList = () => this.http(`/v1/subtitles/list_manual_upload_2_local_job`);
 }
 export default new LibraryApi();

+ 1 - 4
frontend/src/pages/library/BtnDialogLibraryRefresh.vue

@@ -8,14 +8,11 @@
 </template>
 
 <script setup>
-import { useLibrary } from 'pages/library/useLibrary';
+import { refreshCacheLoading, refreshLibrary } from 'pages/library/useLibrary';
 import { useQuasar } from 'quasar';
 
 const $q = useQuasar();
 
-// TODO 这里的useLibrary有BUG,与列表外边不是同一个composable,所以无法更新列表
-const { refreshLibrary, refreshCacheLoading } = useLibrary();
-
 const confirm = () => {
   $q.dialog({
     title: '更新缓存',

+ 29 - 20
frontend/src/pages/library/movies/ListItemMovie.vue

@@ -26,22 +26,20 @@
       <div class="text-grey">1970-01-01</div>
       <q-space />
       <div>
-        <!--        <q-btn v-if="hasSubtitle" color="black"-->
-        <!--               round flat dense icon="closed_caption" @click.stop title="已有字幕">-->
-        <!--          <q-popup-proxy>-->
-        <!--            <q-list dense>-->
-        <!--              <q-item v-for="(item, index) in data.sub_f_path_list" :key="item">-->
-        <!--                <q-item-section side>{{ index + 1 }}.</q-item-section>-->
+        <q-btn v-if="hasSubtitle" color="black" round flat dense icon="closed_caption" @click.stop title="已有字幕">
+          <q-popup-proxy>
+            <q-list dense>
+              <q-item v-for="(item, index) in detialInfo.sub_url_list" :key="item">
+                <q-item-section side>{{ index + 1 }}.</q-item-section>
 
-        <!--                <q-item-section class="overflow-hidden ellipsis" :title="item.split(/\/|\\/).pop()">-->
-        <!--                  <a class="text-primary" :href="getUrl(item)"-->
-        <!--                     target="_blank">{{ item.split(/\/|\\/).pop() }}</a>-->
-        <!--                </q-item-section>-->
-        <!--              </q-item>-->
-        <!--            </q-list>-->
-        <!--          </q-popup-proxy>-->
-        <!--        </q-btn>-->
-        <!--        <q-btn v-else color="grey" round flat dense icon="closed_caption" @click.stop title="没有字幕" />-->
+                <q-item-section class="overflow-hidden ellipsis" :title="item.split(/\/|\\/).pop()">
+                  <a class="text-primary" :href="getUrl(item)" target="_blank">{{ item.split(/\/|\\/).pop() }}</a>
+                </q-item-section>
+              </q-item>
+            </q-list>
+          </q-popup-proxy>
+        </q-btn>
+        <q-btn v-else color="grey" round flat dense icon="closed_caption" @click.stop title="没有字幕" />
         <q-btn
           v-if="isSkipped"
           color="grey"
@@ -70,10 +68,10 @@
 </template>
 
 <script setup>
-import { onMounted, ref } from 'vue';
+import { computed, onMounted, ref } from 'vue';
 import LibraryApi from 'src/api/LibraryApi';
 import { SystemMessage } from 'src/utils/Message';
-import { VIDEO_TYPE_MOVIE, VIDEO_TYPE_TV } from 'src/constants/SettingConstants';
+import { VIDEO_TYPE_MOVIE } from 'src/constants/SettingConstants';
 import { useQuasar } from 'quasar';
 import { getUrl } from 'pages/library/useLibrary';
 
@@ -84,6 +82,7 @@ const props = defineProps({
 const $q = useQuasar();
 
 const posterInfo = ref(null);
+const detialInfo = ref(null);
 const isSkipped = ref(null);
 
 const getPosterInfo = async () => {
@@ -95,9 +94,18 @@ const getPosterInfo = async () => {
   posterInfo.value = res;
 };
 
+const getDetailInfo = async () => {
+  const [res] = await LibraryApi.getMovieDetail({
+    name: props.data.name,
+    main_root_dir_f_path: props.data.main_root_dir_f_path,
+    video_f_path: props.data.video_f_path,
+  });
+  detialInfo.value = res;
+};
+
 const getIsSkipped = async () => {
   const [res] = await LibraryApi.getSkipInfo({
-    video_type: VIDEO_TYPE_TV,
+    video_type: VIDEO_TYPE_MOVIE,
     physical_video_file_full_path: props.data.video_f_path,
     is_bluray: false,
     is_skip: true,
@@ -113,7 +121,7 @@ const handleIgnore = async () => {
     persistent: true,
   }).onOk(async () => {
     const [res] = await LibraryApi.setSkipInfo({
-      video_type: VIDEO_TYPE_TV,
+      video_type: VIDEO_TYPE_MOVIE,
       physical_video_file_full_path: props.data.video_f_path,
       is_bluray: false,
       is_skip: !isSkipped.value,
@@ -125,7 +133,7 @@ const handleIgnore = async () => {
   });
 };
 
-// const hasSubtitle = computed(() => props.data.sub_f_path_list.length > 0);
+const hasSubtitle = computed(() => detialInfo.value?.sub_url_list.length > 0);
 
 const downloadSubtitle = async () => {
   $q.dialog({
@@ -160,6 +168,7 @@ const downloadSubtitle = async () => {
 onMounted(() => {
   getPosterInfo();
   getIsSkipped();
+  getDetailInfo();
 });
 </script>
 

+ 48 - 0
frontend/src/pages/library/tvs/BtnUploadSubtitle.vue

@@ -0,0 +1,48 @@
+<template>
+  <div v-if="isInQueue" class="row items-center q-gutter-xs">
+    <q-spinner-hourglass color="primary" size="22px" />
+    <div style="font-size: 90%">字幕上传中</div>
+  </div>
+  <q-btn v-else color="primary" round flat dense icon="upload" title="上传字幕" @click="handleUploadClick" />
+
+  <q-input v-show="false" type="file" ref="qFile" v-model="uploadFile" accept=".srt,.ass,.ssa,.sbv,.webvtt" />
+</template>
+
+<script setup>
+import { ref, watch, watchEffect } from 'vue';
+import { getSubtitleUploadList, subtitleUploadList } from 'pages/library/useLibrary';
+import LibraryApi from 'src/api/LibraryApi';
+import { SystemMessage } from 'src/utils/Message';
+
+const props = defineProps({
+  data: Object,
+});
+
+const uploadFile = ref(null);
+const qFile = ref(null);
+const isInQueue = ref(false);
+
+watchEffect(() => {
+  isInQueue.value = subtitleUploadList.value.some(
+    (item) => item.video_f_path === props.data.video_f_path && item.sub_f_path === props.data.sub_f_path
+  );
+});
+
+const handleUploadClick = () => {
+  qFile.value.$el.click();
+};
+
+const upload = async () => {
+  const formData = new FormData();
+  formData.append('video_f_path', props.data.video_f_path);
+  formData.append('file', uploadFile.value[0]);
+  isInQueue.value = true;
+  await LibraryApi.uploadSubtitle(formData);
+  SystemMessage.success('字幕上传成功');
+  await getSubtitleUploadList();
+};
+
+watch(uploadFile, () => {
+  upload();
+});
+</script>

+ 119 - 85
frontend/src/pages/library/tvs/DialogTVDetail.vue

@@ -4,93 +4,111 @@
   </span>
 
   <q-dialog v-model="visible">
-    <q-card style="width: 600px">
+    <q-card style="width: 800px; max-width: 800px">
       <q-card-section>
         <div class="text-h6">{{ data.name }} 剧集列表</div>
       </q-card-section>
 
-      <q-card-section class="row items-center q-ml-md q-py-none">
-        <q-checkbox
-          :model-value="selectAllValue"
-          indeterminate-value="maybe"
-          @click="handleSelectAll"
-          title="全选/反选"
+      <q-tabs v-model="tab" dense active-color="primary" indicator-color="primary" align="justify" narrow-indicator>
+        <q-tab
+          v-for="item in categoryVideos"
+          :key="item.season"
+          :name="item.season"
+          :label="`第${item.season}季`"
+          style="max-width: 150px"
         />
+      </q-tabs>
 
-        <q-btn
-          class="btn-download"
-          color="primary"
-          label="下载选中"
-          flat
-          :disable="selection.length === 0"
-          @click="downloadSelection"
-        ></q-btn>
-      </q-card-section>
-
-      <q-separator class="q-my-sm" />
+      <q-separator />
 
       <q-card-section style="max-height: 40vh; overflow: auto">
-        <q-list dense>
-          <q-item v-for="item in sortedVideos" :key="item.name">
-            <q-item-section side top>
-              <q-checkbox v-model="selection" :val="item" />
-            </q-item-section>
-            <q-item-section>第 {{ item.season }} 季 {{ pandStart2(item.episode) }} 集</q-item-section>
-            <q-item-section side>
-              <q-btn
-                v-if="item.sub_f_path_list.length"
-                color="black"
-                round
-                flat
-                dense
-                icon="closed_caption"
-                @click.stop
-                title="已有字幕"
-              >
-                <q-popup-proxy anchor="top right">
-                  <q-list dense>
-                    <q-item v-for="(item1, index) in item.sub_f_path_list" :key="item1">
-                      <q-item-section side>{{ index + 1 }}.</q-item-section>
-
-                      <q-item-section class="overflow-hidden ellipsis" :title="item1.split(/\/|\\/).pop()">
-                        <a class="text-primary" :href="getUrl(item1)" target="_blank">{{
-                          item1.split(/\/|\\/).pop()
-                        }}</a>
-                      </q-item-section>
-                    </q-item>
-                  </q-list>
-                </q-popup-proxy>
-              </q-btn>
-              <q-btn v-else color="grey" round flat dense icon="closed_caption" @click.stop title="没有字幕" />
-            </q-item-section>
-
-            <q-item-section side>
-              <q-btn
-                class="btn-download"
-                color="primary"
-                round
-                flat
-                dense
-                icon="download_for_offline"
-                title="下载字幕"
-                @click="downloadSubtitle(item)"
-              ></q-btn>
-            </q-item-section>
-          </q-item>
-        </q-list>
+        <div class="row items-center q-ml-md q-py-none">
+          <q-checkbox
+            :model-value="selectAllValue"
+            indeterminate-value="maybe"
+            @click="handleSelectAll"
+            title="全选/反选"
+          />
+
+          <q-btn
+            class="btn-download"
+            color="primary"
+            label="下载选中"
+            flat
+            :disable="selection.length === 0"
+            @click="downloadSelection"
+          ></q-btn>
+        </div>
+
+        <q-tab-panels v-model="tab" animated>
+          <q-tab-panel v-for="{ season, episodes } in categoryVideos" :key="season" :name="season" style="padding: 0">
+            <q-list dense>
+              <q-item v-for="item in episodes" :key="item.name">
+                <q-item-section side top>
+                  <q-checkbox v-model="selection" :val="item" />
+                </q-item-section>
+                <q-item-section>第 {{ pandStart2(item.episode) }} 集</q-item-section>
+                <q-item-section side>
+                  <btn-upload-subtitle :data="item" />
+                </q-item-section>
+                <q-item-section side>
+                  <q-btn
+                    v-if="item.sub_f_path_list.length"
+                    color="black"
+                    round
+                    flat
+                    dense
+                    icon="closed_caption"
+                    @click.stop
+                    title="已有字幕"
+                  >
+                    <q-popup-proxy anchor="top right">
+                      <q-list dense>
+                        <q-item v-for="(item1, index) in item.sub_f_path_list" :key="item1">
+                          <q-item-section side>{{ index + 1 }}.</q-item-section>
+
+                          <q-item-section class="overflow-hidden ellipsis" :title="item1.split(/\/|\\/).pop()">
+                            <a class="text-primary" :href="getUrl(item1)" target="_blank">{{
+                              item1.split(/\/|\\/).pop()
+                            }}</a>
+                          </q-item-section>
+                        </q-item>
+                      </q-list>
+                    </q-popup-proxy>
+                  </q-btn>
+                  <q-btn v-else color="grey" round flat dense icon="closed_caption" @click.stop title="没有字幕" />
+                </q-item-section>
+
+                <q-item-section side>
+                  <q-btn
+                    class="btn-download"
+                    color="primary"
+                    round
+                    flat
+                    dense
+                    icon="download_for_offline"
+                    title="下载字幕"
+                    @click="downloadSubtitle(item)"
+                  ></q-btn>
+                </q-item-section>
+              </q-item>
+            </q-list>
+          </q-tab-panel>
+        </q-tab-panels>
       </q-card-section>
     </q-card>
   </q-dialog>
 </template>
 
 <script setup>
-import { computed, ref } from 'vue';
+import { computed, ref, watch } from 'vue';
 import LibraryApi from 'src/api/LibraryApi';
 import { SystemMessage } from 'src/utils/Message';
 import { VIDEO_TYPE_TV } from 'src/constants/SettingConstants';
 import config from 'src/config';
 import { useQuasar } from 'quasar';
 import { useSelection } from 'src/composables/useSelection';
+import BtnUploadSubtitle from 'pages/library/tvs/BtnUploadSubtitle';
 
 const props = defineProps({
   data: Object,
@@ -98,26 +116,42 @@ const props = defineProps({
 
 const $q = useQuasar();
 
-// 按季度、剧集排序
-const sortedVideos = computed(() =>
-  [...props.data.one_video_info].sort((a, b) => {
-    if (a.season > b.season) {
-      return 1;
-    }
-    if (a.season < b.season) {
-      return -1;
-    }
-    if (a.episode > b.episode) {
-      return 1;
-    }
-    if (a.episode < b.episode) {
-      return -1;
+const categoryVideos = computed(() => {
+  // [{season: episodes: []}]
+  const result = [];
+  props.data?.one_video_info.forEach((item) => {
+    const { season } = item;
+    const index = result.findIndex((e) => e.season === season);
+    if (index === -1) {
+      result.push({
+        season,
+        episodes: [item],
+      });
+    } else {
+      result[index].episodes.push(item);
     }
-    return 0;
-  })
-);
+  });
+  result.sort((a, b) => a.season - b.season);
+  result.forEach((item) => {
+    item.episodes.sort((a, b) => a.episode - b.episode);
+  });
+  return result;
+});
+
+const tab = ref(null);
 
-const { selectAllValue, handleSelectAll, selection } = useSelection(sortedVideos);
+watch(categoryVideos, () => {
+  if (categoryVideos.value.length) {
+    tab.value = categoryVideos.value[0].season;
+  }
+});
+
+const currentTabEpisodes = computed(() => categoryVideos.value.find((e) => e.season === tab.value)?.episodes ?? []);
+
+const { selectAllValue, handleSelectAll, selection } = useSelection(currentTabEpisodes);
+watch(tab, () => {
+  selection.value = [];
+});
 
 const pandStart2 = (num) => {
   if (num < 10) {

+ 28 - 17
frontend/src/pages/library/tvs/ListItemTV.vue

@@ -16,26 +16,26 @@
       <div class="text-grey">1970-01-01</div>
       <q-space />
       <div>
-        <!--        <dialog-t-v-detail :data="data">-->
-        <!--          <q-btn-->
-        <!--            v-if="hasSubtitleVideoCount > 0"-->
-        <!--            color="black"-->
-        <!--            flat-->
-        <!--            dense-->
-        <!--            icon="closed_caption"-->
-        <!--            :label="`${hasSubtitleVideoCount}/${data.one_video_info.length}`"-->
-        <!--            title="已有字幕"-->
-        <!--          />-->
-        <!--          <q-btn v-else color="grey" round flat dense icon="closed_caption" title="没有字幕" />-->
-        <!--        </dialog-t-v-detail>-->
+        <dialog-t-v-detail :data="detailInfo">
+          <q-btn
+            v-if="hasSubtitleVideoCount > 0"
+            color="black"
+            flat
+            dense
+            icon="closed_caption"
+            :label="`${hasSubtitleVideoCount}/${detailInfo.one_video_info.length}`"
+            title="已有字幕"
+          />
+          <q-btn v-else color="grey" round flat dense icon="closed_caption" title="没有字幕" />
+        </dialog-t-v-detail>
       </div>
     </div>
   </q-card>
 </template>
 
 <script setup>
-import { onMounted, ref } from 'vue';
-// import DialogTVDetail from 'pages/library/tvs/DialogTVDetail';
+import { computed, onMounted, ref } from 'vue';
+import DialogTVDetail from 'pages/library/tvs/DialogTVDetail';
 import LibraryApi from 'src/api/LibraryApi';
 import { getUrl } from 'pages/library/useLibrary';
 import { VIDEO_TYPE_TV } from 'src/constants/SettingConstants';
@@ -45,6 +45,7 @@ const props = defineProps({
 });
 
 const posterInfo = ref(null);
+const detailInfo = ref(null);
 const isSkipped = ref(null);
 
 const getPosterInfo = async () => {
@@ -56,6 +57,15 @@ const getPosterInfo = async () => {
   posterInfo.value = res;
 };
 
+const getDetailInfo = async () => {
+  const [res] = await LibraryApi.getTvDetail({
+    name: props.data.name,
+    main_root_dir_f_path: props.data.main_root_dir_f_path,
+    root_dir_path: props.data.root_dir_path,
+  });
+  detailInfo.value = res;
+};
+
 const getIsSkipped = async () => {
   const [res] = await LibraryApi.getSkipInfo({
     video_type: VIDEO_TYPE_TV,
@@ -66,13 +76,14 @@ const getIsSkipped = async () => {
   isSkipped.value = res.is_skip;
 };
 
-// const hasSubtitleVideoCount = computed(
-//   () => props.data.one_video_info.filter((e) => e.sub_f_path_list.length > 0).length
-// );
+const hasSubtitleVideoCount = computed(
+  () => detailInfo.value?.one_video_info.filter((e) => e.sub_f_path_list.length > 0).length
+);
 
 onMounted(() => {
   getPosterInfo();
   getIsSkipped();
+  getDetailInfo();
 });
 </script>
 

+ 58 - 47
frontend/src/pages/library/useLibrary.js

@@ -10,67 +10,78 @@ export const getUrl = (basePath) => config.BACKEND_URL + basePath.split(/\/|\\/)
 // 封面规则
 export const coverRule = ref(LocalStorage.getItem('coverRule') ?? 'poster.jpg');
 
-export const useLibrary = () => {
-  const originMovies = ref([]);
-  const originTvs = ref([]);
-  const refreshCacheLoading = ref(false);
-  const libraryRefreshStatus = ref(null);
+export const originMovies = ref([]);
+export const originTvs = ref([]);
+const movies = computed(() =>
+  originMovies.value.map((movie) => ({
+    ...movie,
+  }))
+);
 
-  const movies = computed(() =>
-    originMovies.value.map((movie) => ({
-      ...movie,
-    }))
-  );
+const tvs = computed(() =>
+  originTvs.value.map((tv) => ({
+    ...tv,
+  }))
+);
+export const refreshCacheLoading = ref(false);
+export const libraryRefreshStatus = ref(null);
+export const subtitleUploadList = ref([]);
 
-  const tvs = computed(() =>
-    originTvs.value.map((tv) => ({
-      ...tv,
-    }))
-  );
+let getRefreshStatusTimer = null;
 
-  let getRefreshStatusTimer = null;
+export const getLibraryRefreshStatus = async () => {
+  const [res] = await LibraryApi.getRefreshStatus();
+  libraryRefreshStatus.value = res.status;
+};
 
-  const getLibraryRefreshStatus = async () => {
-    const [res] = await LibraryApi.getRefreshStatus();
-    libraryRefreshStatus.value = res.status;
-  };
+export const getLibraryList = async () => {
+  const [res, err] = await LibraryApi.getList();
+  if (err !== null) {
+    SystemMessage.error(err.message);
+  } else {
+    originMovies.value = res.movie_infos_v2;
+    originTvs.value = res.season_infos_v2;
+  }
+};
 
-  const getLibraryList = async () => {
-    const [res, err] = await LibraryApi.getList();
-    if (err !== null) {
-      SystemMessage.error(err.message);
-    } else {
-      originMovies.value = res.movie_infos_v2;
-      originTvs.value = res.season_infos_v2;
-    }
-  };
+export const refreshLibrary = async () => {
+  refreshCacheLoading.value = true;
+  const [, err] = await LibraryApi.refreshLibrary();
+  if (err !== null) {
+    SystemMessage.error(err.message);
+  } else {
+    libraryRefreshStatus.value = null;
+    getRefreshStatusTimer = setInterval(() => {
+      getLibraryRefreshStatus();
+    }, 1000);
+    await until(libraryRefreshStatus).toBe('stopped');
+    clearInterval(getRefreshStatusTimer);
+    getRefreshStatusTimer = null;
+    await getLibraryList();
+    SystemMessage.success('更新成功');
+  }
+  refreshCacheLoading.value = false;
+};
 
-  const refreshLibrary = async () => {
-    refreshCacheLoading.value = true;
-    const [, err] = await LibraryApi.refreshLibrary();
-    if (err !== null) {
-      SystemMessage.error(err.message);
-    } else {
-      libraryRefreshStatus.value = null;
-      getRefreshStatusTimer = setInterval(() => {
-        getLibraryRefreshStatus();
-      }, 1000);
-      await until(libraryRefreshStatus).toBe('stopped');
-      clearInterval(getRefreshStatusTimer);
-      getRefreshStatusTimer = null;
-      await getLibraryList();
-      SystemMessage.success('更新成功');
-    }
-    refreshCacheLoading.value = false;
-  };
+export const getSubtitleUploadList = async () => {
+  const [res] = await LibraryApi.getSubTitleQueueList();
+  subtitleUploadList.value = res.jobs;
+};
+
+export const useLibrary = () => {
+  const getSubtitleUploadListTimer = setInterval(() => {
+    getSubtitleUploadList();
+  }, 5000);
 
   onMounted(() => {
     getLibraryList();
     getLibraryRefreshStatus();
+    getSubtitleUploadList();
   });
 
   onBeforeUnmount(() => {
     clearInterval(getRefreshStatusTimer);
+    clearInterval(getSubtitleUploadListTimer);
   });
 
   return {