ソースを参照

feat(frontend): 视频/连续剧单集添加csf api字幕搜索

Myon 2 年 前
コミット
cbb80058fe

+ 2 - 0
frontend/.env.development

@@ -1 +1,3 @@
 BACKEND_URL=/api
+CSF_SUBTITLES_API_URL=https://api.subtitle.best/share-sub
+CSF_SUBTITLES_API_KEY=5akwmGAbuFWqgaZf9QwT

+ 2 - 0
frontend/.env.production

@@ -1 +1,3 @@
 BACKEND_URL=
+CSF_SUBTITLES_API_URL=https://api.subtitle.best/share-sub
+CSF_SUBTITLES_API_KEY=5akwmGAbuFWqgaZf9QwT

+ 23 - 0
frontend/src/api/CsfSubtitlesApi.js

@@ -0,0 +1,23 @@
+import { createCsfSubtitlesRequest } from 'src/utils/http';
+import config from 'src/config';
+
+class CsfSubtitlesApi {
+  // 如果没设置baseUrl,则默认使用当前相对路径
+  BaseUrl = config.CSF_SUBTITLES_API_URL;
+
+  http(url, ...option) {
+    return createCsfSubtitlesRequest(`${this.BaseUrl}${url}`, ...option);
+  }
+
+  searchMovie = (data) => this.http('/v1/search-movie', data, 'POST');
+
+  searchTvEps = (data) => this.http('/v1/search-tv-eps', data, 'POST');
+
+  searchTvSeasonPackage = (data) => this.http('/v1/search-tv-season-package', data, 'POST');
+
+  searchTvSeasonPackageId = (data) => this.http('/v1/search-tv-season-package-id', data, 'POST');
+
+  getDownloadUrl = (data) => this.http('/v1/get-dl-url', data, 'POST');
+}
+
+export default new CsfSubtitlesApi();

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

@@ -8,6 +8,8 @@ class LibraryApi extends BaseApi {
 
   getList = () => this.http('/v1/video/list/video_main_list');
 
+  getImdbId = (data) => this.http('/v1/preview/video_f_path_2_imdb_info', data, 'POST');
+
   downloadSubtitle = (data) => this.http(`/v1/video/list/add`, data, 'POST');
 
   getMoviePoster = (data) => this.http(`/v1/video/list/movie_poster`, data, 'POST');

+ 2 - 0
frontend/src/config/index.js

@@ -3,4 +3,6 @@ export default {
     // 如果.env文件设置了 BACKEND_URL,则使用设置的值,否则使用相对路径
     return process.env.BACKEND_URL || new URL(window.location.href).pathname.replace(/\/$/, '');
   },
+  CSF_SUBTITLES_API_URL: process.env.CSF_SUBTITLES_API_URL,
+  CSF_SUBTITLES_API_KEY: process.env.CSF_SUBTITLES_API_KEY,
 };

+ 17 - 2
frontend/src/pages/library/BtnDialogPreviewVideo.vue

@@ -1,5 +1,5 @@
 <template>
-  <q-btn color="primary" icon="smart_display" flat dense v-bind="$attrs" @click="visible = true" title="预览" />
+  <q-btn color="primary" icon="smart_display" flat dense v-bind="$attrs" @click="handleBtnClick" title="预览" />
 
   <q-dialog
     v-model="visible"
@@ -53,10 +53,12 @@ const $q = useQuasar();
 
 const props = defineProps({
   path: String,
+  onBtnClick: Function,
   subList: {
     type: Array,
     default: () => [],
   },
+  subtitleType: String,
 });
 
 const visible = ref(false);
@@ -64,6 +66,18 @@ const artInstance = ref(null);
 const selectedSub = ref(null);
 const checkResult = ref(null);
 
+const handleBtnClick = async () => {
+  if (props.onBtnClick) {
+    props.onBtnClick((flag) => {
+      if (flag) {
+        visible.value = true;
+      }
+    });
+  } else {
+    visible.value = true;
+  }
+};
+
 const handleGetArtInstance = (instance) => {
   artInstance.value = instance;
 };
@@ -90,7 +104,8 @@ const artOption = computed(() => ({
   autoSize: true,
   url: `${config.BACKEND_URL}/v1/preview/playlist/${encode(encodeURIComponent(props.path))}`,
   subtitle: {
-    url: getUrl(selectedSub.value),
+    url: selectedSub.value.startsWith('blob') ? selectedSub.value : getUrl(selectedSub.value),
+    type: props.subtitleType,
   },
   type: 'm3u8',
   customType: {

+ 37 - 60
frontend/src/pages/library/BtnDialogSearchSubtitle.vue

@@ -5,83 +5,60 @@
     <q-card style="min-width: 70vw">
       <q-card-section>
         <div class="text-h6 text-grey-8">字幕搜索</div>
-        <div class="text-grey">点击关键字跳转到网站搜索</div>
       </q-card-section>
 
       <q-separator />
 
-      <q-card-section v-if="searchInfo">
-        <q-list separator>
-          <q-item v-for="url in searchInfo?.search_url" :key="url">
-            <q-item-section top side style="width: 200px" class="text-bold text-black">
-              {{ getDomain(url) }}
-            </q-item-section>
-            <q-item-section>
-              <div class="row q-gutter-sm">
-                <a
-                  v-for="item in searchInfo?.key_words"
-                  :key="item"
-                  :href="getSearchUrl(url, item)"
-                  target="_blank"
-                  style="text-decoration: none"
-                >
-                  <q-badge class="cursor-pointer" color="secondary" title="点击跳转到网站搜索">{{ item }}</q-badge>
-                </a>
-              </div>
-            </q-item-section>
-          </q-item>
-        </q-list>
-      </q-card-section>
-      <q-card-section v-else>
-        <div class="row items-center justify-center" style="height: 200px">
-          <q-spinner size="30px" />
-        </div>
-      </q-card-section>
+      <q-tabs
+        v-model="tab"
+        dense
+        active-color="primary"
+        indicator-color="primary"
+        align="justify"
+        narrow-indicator
+        style="max-width: 300px"
+      >
+        <q-tab name="csf" label="CSF API" />
+        <q-tab name="manual" label="手动搜索" />
+      </q-tabs>
+
+      <q-tab-panels v-model="tab" animated keep-alive>
+        <q-tab-panel name="csf">
+          <search-panel-csf-api :path="path" :imdb-id="imdbId" :is-movie="isMovie" :search-package="searchPackage" />
+        </q-tab-panel>
+
+        <q-tab-panel name="manual">
+          <search-panel-manual :is-movie="isMovie" :path="path" />
+        </q-tab-panel>
+      </q-tab-panels>
     </q-card>
   </q-dialog>
 </template>
 
 <script setup>
 import { ref } from 'vue';
-import LibraryApi from 'src/api/LibraryApi';
-import { SystemMessage } from 'src/utils/message';
+import SearchPanelManual from 'pages/library/SearchPanelManual.vue';
+import SearchPanelCsfApi from 'pages/library/SearchPanelCsfApi.vue';
 
-const props = defineProps({
+defineProps({
   path: String,
+  imdbId: String,
   isMovie: {
     type: Boolean,
     default: false,
   },
+  searchPackage: {
+    type: Boolean,
+    default: false,
+  },
+  season: {
+    type: Number,
+  },
+  episode: {
+    type: Number,
+  },
 });
 
 const visible = ref(false);
-const searchInfo = ref(null);
-
-const getSearchInfo = async () => {
-  const [data, err] = await LibraryApi.getSearchSubtitleInfo({
-    video_f_path: props.path,
-    is_movie: props.isMovie,
-  });
-  if (err !== null) {
-    SystemMessage.error(err.message);
-  }
-  searchInfo.value = data;
-};
-
-const getDomain = (url) => {
-  const reg = /https?:\/\/([^/]+)/;
-  const result = reg.exec(url);
-  return result[1];
-};
-
-const getSearchUrl = (url, keyword) => {
-  if (url.includes('%s')) {
-    return url.replace('%s', encodeURIComponent(keyword));
-  }
-  return url + encodeURIComponent(keyword);
-};
-
-const handleBeforeShow = () => {
-  getSearchInfo();
-};
+const tab = ref('csf');
 </script>

+ 192 - 0
frontend/src/pages/library/SearchPanelCsfApi.vue

@@ -0,0 +1,192 @@
+<template>
+  <div style="min-height: 300px">
+    <q-list v-if="csfSearchResult?.length" separator>
+      <q-item v-for="item in csfSearchResult" :key="item.sub_sha256">
+        <q-item-section>
+          {{ item.title }}
+        </q-item-section>
+        <q-item-section side>
+          <div class="row">
+            <btn-dialog-preview-video
+              :path="path"
+              :sub-list="[selectedSubUrl]"
+              :on-btn-click="(callback) => handlePreviewClick(item, callback)"
+              :subtitle-type="selectedItem?.ext.replace('.', '')"
+            />
+            <q-btn color="primary" icon="download" flat dense @click="handleDownloadCsfSub(item)" title="下载" />
+          </div>
+        </q-item-section>
+      </q-item>
+    </q-list>
+    <div v-else-if="!loading" class="text-grey">
+      未搜索到数据,<q-btn flat label="重试" color="primary" dense @click="searchCsf" />
+    </div>
+    <q-inner-loading :showing="loading">
+      <q-spinner size="50px" color="primary" />
+    </q-inner-loading>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, ref } from 'vue';
+import LibraryApi from 'src/api/LibraryApi';
+import { SystemMessage } from 'src/utils/message';
+import CsfSubtitlesApi from 'src/api/CsfSubtitlesApi';
+import BtnDialogPreviewVideo from 'pages/library/BtnDialogPreviewVideo.vue';
+import { getSubtitleUploadList } from 'pages/library/use-library';
+import eventBus from 'vue3-eventbus';
+import { LocalStorage } from 'quasar';
+
+const props = defineProps({
+  path: String,
+  imdbId: String,
+  isMovie: {
+    type: Boolean,
+    default: false,
+  },
+  searchPackage: {
+    type: Boolean,
+    default: false,
+  },
+  season: {
+    type: Number,
+  },
+  episode: {
+    type: Number,
+  },
+});
+
+// 上次请求时间
+let lastRequestApiTime = LocalStorage.getItem('lastRequestApiTime') || 0;
+// 最小请求间隔
+const minRequestApiInterval = 15 * 1000;
+
+const checkOk = () => {
+  const now = Date.now();
+  if (now - lastRequestApiTime < minRequestApiInterval) {
+    return false;
+  }
+  lastRequestApiTime = now;
+  LocalStorage.set('lastRequestApiTime', now);
+  return true;
+};
+
+const waitRequestReady = async () => {
+  // 每100ms检查一次,直到请求间隔大于最小请求间隔
+  while (!checkOk()) {
+    // eslint-disable-next-line no-await-in-loop
+    await new Promise((resolve) => {
+      setTimeout(resolve, 100);
+    });
+  }
+};
+
+const loading = ref(false);
+const csfSearchResult = ref(null);
+const selectedSubBlob = ref(null);
+const selectedItem = ref(null);
+// blob缓存
+const cacheBlob = new Map();
+const selectedSubUrl = computed(() => {
+  if (selectedSubBlob.value) {
+    return URL.createObjectURL(selectedSubBlob.value);
+  }
+  return null;
+});
+
+const searchCsf = async () => {
+  loading.value = true;
+  const [d, e] = await LibraryApi.getImdbId({
+    is_movie: props.isMovie,
+    video_f_path: props.path,
+  });
+  if (e) {
+    SystemMessage.error(e.message);
+    loading.value = false;
+    return;
+  }
+  const imdbId = d?.imdb_id;
+  await waitRequestReady();
+  if (props.isMovie) {
+    const [data, err] = await CsfSubtitlesApi.searchMovie({
+      imdb_id: imdbId,
+    });
+    if (err !== null) {
+      SystemMessage.error(err.message);
+    } else {
+      csfSearchResult.value = data.subtitles;
+    }
+  } else if (!props.searchPackage) {
+    const [data, err] = await CsfSubtitlesApi.searchTvEps({
+      imdb_id: imdbId,
+      season: props.season,
+      episode: props.episode,
+    });
+    if (err !== null) {
+      SystemMessage.error(err.message);
+    } else {
+      csfSearchResult.value = data.subtitles;
+    }
+  } else {
+    // TODO: search package
+  }
+  loading.value = false;
+};
+
+const fetchSubtitleBlob = async (item) => {
+  selectedItem.value = item;
+  if (cacheBlob.has(item.sub_sha256)) {
+    selectedSubBlob.value = cacheBlob.get(item.sub_sha256);
+    return;
+  }
+  selectedSubBlob.value = null;
+  loading.value = true;
+  await waitRequestReady();
+
+  const [data, err] = await CsfSubtitlesApi.getDownloadUrl({
+    token: item.token,
+    sub_sha256: item.sub_sha256,
+  });
+  if (err !== null) {
+    SystemMessage.error(err.message);
+  } else {
+    // fetch资源,获取blob url
+    const res = await fetch(data.download_link);
+    const blob = await res.blob();
+    cacheBlob.set(item.sub_sha256, blob);
+    selectedSubBlob.value = blob;
+  }
+  loading.value = false;
+};
+
+const handleDownloadCsfSub = async (item) => {
+  await fetchSubtitleBlob(item);
+
+  if (!selectedSubBlob.value) {
+    return;
+  }
+
+  // 上传
+  const formData = new FormData();
+  formData.append('video_f_path', props.path);
+  formData.append('file', new File([selectedSubBlob.value], item.title, { type: 'text/plain' }));
+  await LibraryApi.uploadSubtitle(formData);
+  await getSubtitleUploadList();
+  eventBus.emit('subtitle-uploaded');
+
+  SystemMessage.success('已下载到库中');
+};
+
+const handlePreviewClick = async (item, callback) => {
+  await fetchSubtitleBlob(item);
+  if (selectedSubUrl.value) {
+    callback(true);
+  } else {
+    callback(false);
+  }
+};
+
+onMounted(() => {
+  searchCsf();
+});
+</script>

+ 72 - 0
frontend/src/pages/library/SearchPanelManual.vue

@@ -0,0 +1,72 @@
+<template>
+  <div style="min-height: 300px">
+    <div class="text-grey">点击关键字跳转到网站搜索</div>
+    <q-list separator>
+      <q-item v-for="url in searchInfo?.search_url" :key="url">
+        <q-item-section top side style="width: 200px" class="text-bold text-black">
+          {{ getDomain(url) }}
+        </q-item-section>
+        <q-item-section>
+          <div class="row q-gutter-sm">
+            <a
+              v-for="item in searchInfo?.key_words"
+              :key="item"
+              :href="getSearchUrl(url, item)"
+              target="_blank"
+              style="text-decoration: none"
+            >
+              <q-badge class="cursor-pointer" color="secondary" title="点击跳转到网站搜索">{{ item }}</q-badge>
+            </a>
+          </div>
+        </q-item-section>
+      </q-item>
+    </q-list>
+    <q-inner-loading :showing="!searchInfo">
+      <q-spinner size="50px" color="primary" />
+    </q-inner-loading>
+  </div>
+</template>
+
+<script setup>
+import { onMounted, ref } from 'vue';
+import LibraryApi from 'src/api/LibraryApi';
+import { SystemMessage } from 'src/utils/message';
+
+const props = defineProps({
+  path: String,
+  isMovie: {
+    type: Boolean,
+    default: false,
+  },
+});
+
+const searchInfo = ref(null);
+
+const getSearchInfo = async () => {
+  const [data, err] = await LibraryApi.getSearchSubtitleInfo({
+    video_f_path: props.path,
+    is_movie: props.isMovie,
+  });
+  if (err !== null) {
+    SystemMessage.error(err.message);
+  }
+  searchInfo.value = data;
+};
+
+const getDomain = (url) => {
+  const reg = /https?:\/\/([^/]+)/;
+  const result = reg.exec(url);
+  return result[1];
+};
+
+const getSearchUrl = (url, keyword) => {
+  if (url.includes('%s')) {
+    return url.replace('%s', encodeURIComponent(keyword));
+  }
+  return url + encodeURIComponent(keyword);
+};
+
+onMounted(() => {
+  getSearchInfo();
+});
+</script>

+ 1 - 1
frontend/src/pages/library/movies/ListItemMovie.vue

@@ -47,7 +47,7 @@
         <q-btn v-else color="grey" size="sm" round flat dense icon="closed_caption" @click.stop title="没有字幕" />
       </div>
 
-      <btn-dialog-search-subtitle :path="props.data.video_f_path" is-movie />
+      <btn-dialog-search-subtitle :path="props.data.video_f_path" :imdb-id="data.imdb_id" is-movie />
       <q-space />
 
       <btn-upload-subtitle :path="data.video_f_path" dense size="sm" />

+ 8 - 1
frontend/src/pages/library/tvs/DialogTVDetail.vue

@@ -114,7 +114,14 @@
                 </q-item-section>
 
                 <q-item-section side>
-                  <btn-dialog-search-subtitle size="md" round :path="item.video_f_path" />
+                  <btn-dialog-search-subtitle
+                    size="md"
+                    round
+                    :path="item.video_f_path"
+                    :imdb-id="data.imdb_id"
+                    :season="item.season"
+                    :episode="item.episode"
+                  />
                 </q-item-section>
 
                 <q-item-section side>

+ 8 - 0
frontend/src/utils/http/csf-subtitles-interceptors/auth-header.js

@@ -0,0 +1,8 @@
+import config from 'src/config';
+
+export default {
+  onRequestFullFilled: (req) => {
+    req.headers.Authorization = `Bearer ${config.CSF_SUBTITLES_API_KEY}`;
+    return req;
+  },
+};

+ 29 - 0
frontend/src/utils/http/csf-subtitles-interceptors/base-handler.js

@@ -0,0 +1,29 @@
+const handleError = (error) => {
+  // eslint-disable-next-line
+  console.error('interceptor catch the error!\n', error);
+  let errorMessageText = error.data?.message || error.message || '网络错误';
+  // 权限不足时的处理
+  if (error.status === 401) {
+    errorMessageText = error.data.message || 'Token不可用';
+  }
+
+  const rtData = {
+    error,
+    message: errorMessageText,
+  };
+
+  return Promise.reject(rtData);
+};
+
+export default {
+  onRequestRejected: (error) => handleError(error),
+  onResponseFullFilled: (response) => {
+    const { data } = response;
+    // 正常返回但是code是错误码的情况也需要异常处理
+    if (data?.message !== 'ok' || (data?.code && data?.code > 300)) {
+      return handleError(response);
+    }
+    return response;
+  },
+  onResponseRejected: (error) => handleError(error?.response || error),
+};

+ 54 - 46
frontend/src/utils/http/http-client.js

@@ -4,56 +4,64 @@
 
 import axios from 'axios';
 
-const axiosInstance = axios.create({
-  timeout: 60000, // 超时时间
-  withCredentials: true, // 跨域传递cookie值
-});
+class HttpClient {
+  axiosInstance = null;
 
-/**
- * 注册拦截器
- */
-const registerInterceptor = (interceptor) => {
-  const { onRequestFullFilled, onRequestRejected, onResponseFullFilled, onResponseRejected } = interceptor;
-  if (onRequestFullFilled || onRequestRejected) {
-    axiosInstance.interceptors.request.use(onRequestFullFilled, onRequestRejected);
+  constructor() {
+    this.axiosInstance = axios.create({
+      timeout: 60000, // 超时时间
+      withCredentials: false, // 跨域传递cookie值
+    });
   }
-  if (onResponseFullFilled || onResponseRejected) {
-    axiosInstance.interceptors.response.use(onResponseFullFilled, onResponseRejected);
+
+  /**
+   * 注册拦截器
+   */
+  registerInterceptor(interceptor) {
+    const { onRequestFullFilled, onRequestRejected, onResponseFullFilled, onResponseRejected } = interceptor;
+    if (onRequestFullFilled || onRequestRejected) {
+      this.axiosInstance.interceptors.request.use(onRequestFullFilled, onRequestRejected);
+    }
+    if (onResponseFullFilled || onResponseRejected) {
+      this.axiosInstance.interceptors.response.use(onResponseFullFilled, onResponseRejected);
+    }
   }
-};
 
-/**
- * 根据目录自动注册拦截器
- * @param contextModules
- */
-const registerInterceptorsFromDirectory = (contextModules) => {
-  const handlers = contextModules.keys().reduce((cur, key) => {
-    cur.push(contextModules(key).default);
-    return cur;
-  }, []);
-  handlers.forEach(registerInterceptor);
-};
+  /**
+   * 根据目录自动注册拦截器
+   * @param contextModules
+   */
+  registerInterceptorsFromDirectory(contextModules) {
+    const handlers = contextModules.keys().reduce((cur, key) => {
+      cur.push(contextModules(key).default);
+      return cur;
+    }, []);
+    handlers.forEach((hanlder) => {
+      this.registerInterceptor(hanlder);
+    });
+  }
 
-// 通用请求方法
-const createRequest = (url = '', data = {}, type = 'GET', config = {}) => {
-  config.headers = config.headers || {};
-  const axiosConfig = Object.assign(config, {
-    method: type.toUpperCase(),
-    url,
-    headers: { ...config.headers },
-  });
-  if (['DELETE', 'GET'].includes(type.toUpperCase())) {
-    config.params = data;
-  } else {
-    config.data = data;
+  // 通用请求方法
+  createRequest(url = '', data = {}, type = 'GET', config = {}) {
+    config.headers = config.headers || {};
+    const axiosConfig = Object.assign(config, {
+      method: type.toUpperCase(),
+      url,
+      headers: { ...config.headers },
+    });
+    if (['DELETE', 'GET'].includes(type.toUpperCase())) {
+      config.params = data;
+    } else {
+      config.data = data;
+    }
+    return (
+      this.axiosInstance
+        .request(axiosConfig)
+        // 分别处理直接返回的数据源和{result: 1, message: '', data: {}|[]}形式的数据源
+        .then((response) => [response.data, null])
+        .catch((error) => [null, error])
+    );
   }
-  return (
-    axiosInstance
-      .request(axiosConfig)
-      // 分别处理直接返回的数据源和{result: 1, message: '', data: {}|[]}形式的数据源
-      .then((response) => [response.data, null])
-      .catch((error) => [null, error])
-  );
-};
+}
 
-export { createRequest, registerInterceptor, registerInterceptorsFromDirectory, axiosInstance };
+export default HttpClient;

+ 11 - 3
frontend/src/utils/http/index.js

@@ -1,5 +1,13 @@
-import { createRequest, registerInterceptorsFromDirectory } from './http-client';
+import HttpClient from './http-client';
 
-registerInterceptorsFromDirectory(require.context('./interceptors', false, /(?<!noscan)\.js$/));
+const httpClient = new HttpClient();
+httpClient.registerInterceptorsFromDirectory(require.context('./interceptors', false, /(?<!noscan)\.js$/));
+const createRequest = httpClient.createRequest.bind(httpClient);
 
-export { createRequest };
+const csfSubtitlesHttpClient = new HttpClient();
+csfSubtitlesHttpClient.registerInterceptorsFromDirectory(
+  require.context('./csf-subtitles-interceptors', false, /(?<!noscan)\.js$/)
+);
+const createCsfSubtitlesRequest = csfSubtitlesHttpClient.createRequest.bind(csfSubtitlesHttpClient);
+
+export { createRequest, createCsfSubtitlesRequest };