Import.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725
  1. <script setup lang="ts">
  2. import { defineAsyncComponent, ref } from "vue";
  3. import { useRouter } from "vue-router";
  4. import Toast from "toasters";
  5. import { useWebsocketsStore } from "@/stores/websockets";
  6. import { useLongJobsStore } from "@/stores/longJobs";
  7. import { useModalsStore } from "@/stores/modals";
  8. import { useUserAuthStore } from "@/stores/userAuth";
  9. import { TableColumn, TableFilter, TableEvents } from "@/types/advancedTable";
  10. import utils from "@/utils";
  11. const AdvancedTable = defineAsyncComponent(
  12. () => import("@/components/AdvancedTable.vue")
  13. );
  14. const InfoIcon = defineAsyncComponent(
  15. () => import("@/components/InfoIcon.vue")
  16. );
  17. const UserLink = defineAsyncComponent(
  18. () => import("@/components/UserLink.vue")
  19. );
  20. const router = useRouter();
  21. const { socket } = useWebsocketsStore();
  22. const createImport = ref({
  23. stage: 2,
  24. importMethod: "youtube",
  25. youtubeUrl: "",
  26. isImportingOnlyMusic: false
  27. });
  28. const columnDefault = ref<TableColumn>({
  29. sortable: true,
  30. hidable: true,
  31. defaultVisibility: "shown",
  32. draggable: true,
  33. resizable: true,
  34. minWidth: 200,
  35. maxWidth: 600
  36. });
  37. const columns = ref<TableColumn[]>([
  38. {
  39. name: "options",
  40. displayName: "Options",
  41. properties: ["_id", "status"],
  42. sortable: false,
  43. hidable: false,
  44. resizable: false,
  45. minWidth: 160,
  46. defaultWidth: 160
  47. },
  48. {
  49. name: "type",
  50. displayName: "Type",
  51. properties: ["type"],
  52. sortProperty: "type",
  53. minWidth: 120,
  54. defaultWidth: 120
  55. },
  56. {
  57. name: "requestedBy",
  58. displayName: "Requested By",
  59. properties: ["requestedBy"],
  60. sortProperty: "requestedBy"
  61. },
  62. {
  63. name: "requestedAt",
  64. displayName: "Requested At",
  65. properties: ["requestedAt"],
  66. sortProperty: "requestedAt"
  67. },
  68. {
  69. name: "successful",
  70. displayName: "Successful",
  71. properties: ["response"],
  72. sortProperty: "response.successful",
  73. minWidth: 120,
  74. defaultWidth: 120
  75. },
  76. {
  77. name: "alreadyInDatabase",
  78. displayName: "Existing",
  79. properties: ["response"],
  80. sortProperty: "response.alreadyInDatabase",
  81. minWidth: 120,
  82. defaultWidth: 120
  83. },
  84. {
  85. name: "failed",
  86. displayName: "Failed",
  87. properties: ["response"],
  88. sortProperty: "response.failed",
  89. minWidth: 120,
  90. defaultWidth: 120
  91. },
  92. {
  93. name: "status",
  94. displayName: "Status",
  95. properties: ["status"],
  96. sortProperty: "status",
  97. defaultVisibility: "hidden"
  98. },
  99. {
  100. name: "url",
  101. displayName: "URL",
  102. properties: ["query.url"],
  103. sortProperty: "query.url"
  104. },
  105. {
  106. name: "musicOnly",
  107. displayName: "Music Only",
  108. properties: ["query.musicOnly"],
  109. sortProperty: "query.musicOnly",
  110. minWidth: 120,
  111. defaultWidth: 120
  112. },
  113. {
  114. name: "_id",
  115. displayName: "Import ID",
  116. properties: ["_id"],
  117. sortProperty: "_id",
  118. minWidth: 215,
  119. defaultWidth: 215,
  120. defaultVisibility: "hidden"
  121. }
  122. ]);
  123. const filters = ref<TableFilter[]>([
  124. {
  125. name: "_id",
  126. displayName: "Import ID",
  127. property: "_id",
  128. filterTypes: ["exact"],
  129. defaultFilterType: "exact"
  130. },
  131. {
  132. name: "type",
  133. displayName: "Type",
  134. property: "type",
  135. filterTypes: ["exact"],
  136. defaultFilterType: "exact",
  137. dropdown: [["youtube", "YouTube"]]
  138. },
  139. {
  140. name: "requestedBy",
  141. displayName: "Requested By",
  142. property: "requestedBy",
  143. filterTypes: ["contains", "exact", "regex"],
  144. defaultFilterType: "contains"
  145. },
  146. {
  147. name: "requestedAt",
  148. displayName: "Requested At",
  149. property: "requestedAt",
  150. filterTypes: ["datetimeBefore", "datetimeAfter"],
  151. defaultFilterType: "datetimeBefore"
  152. },
  153. {
  154. name: "response.successful",
  155. displayName: "Successful",
  156. property: "response.successful",
  157. filterTypes: [
  158. "numberLesserEqual",
  159. "numberLesser",
  160. "numberGreater",
  161. "numberGreaterEqual",
  162. "numberEquals"
  163. ],
  164. defaultFilterType: "numberLesser"
  165. },
  166. {
  167. name: "response.alreadyInDatabase",
  168. displayName: "Existing",
  169. property: "response.alreadyInDatabase",
  170. filterTypes: [
  171. "numberLesserEqual",
  172. "numberLesser",
  173. "numberGreater",
  174. "numberGreaterEqual",
  175. "numberEquals"
  176. ],
  177. defaultFilterType: "numberLesser"
  178. },
  179. {
  180. name: "response.failed",
  181. displayName: "Failed",
  182. property: "response.failed",
  183. filterTypes: [
  184. "numberLesserEqual",
  185. "numberLesser",
  186. "numberGreater",
  187. "numberGreaterEqual",
  188. "numberEquals"
  189. ],
  190. defaultFilterType: "numberLesser"
  191. },
  192. {
  193. name: "status",
  194. displayName: "Status",
  195. property: "status",
  196. filterTypes: ["contains", "exact", "regex"],
  197. defaultFilterType: "contains"
  198. },
  199. {
  200. name: "url",
  201. displayName: "URL",
  202. property: "query.url",
  203. filterTypes: ["contains", "exact", "regex"],
  204. defaultFilterType: "contains"
  205. },
  206. {
  207. name: "musicOnly",
  208. displayName: "Music Only",
  209. property: "query.musicOnly",
  210. filterTypes: ["exact"],
  211. defaultFilterType: "exact",
  212. dropdown: [
  213. [true, "True"],
  214. [false, "False"]
  215. ]
  216. },
  217. {
  218. name: "status",
  219. displayName: "Status",
  220. property: "status",
  221. filterTypes: ["exact"],
  222. defaultFilterType: "exact",
  223. dropdown: [
  224. ["success", "Success"],
  225. ["in-progress", "In Progress"],
  226. ["failed", "Failed"]
  227. ]
  228. }
  229. ]);
  230. const events = ref<TableEvents>({
  231. adminRoom: "import",
  232. updated: {
  233. event: "admin.importJob.updated",
  234. id: "importJob._id",
  235. item: "importJob"
  236. },
  237. removed: {
  238. event: "admin.importJob.removed",
  239. id: "jobId"
  240. }
  241. });
  242. const { openModal } = useModalsStore();
  243. const { setJob } = useLongJobsStore();
  244. const { hasPermission } = useUserAuthStore();
  245. const openAdvancedTable = importJob => {
  246. const filter = {
  247. appliedFilters: [
  248. {
  249. data: importJob._id,
  250. filter: {
  251. name: "importJob",
  252. displayName: "Import Job",
  253. property: "importJob",
  254. filterTypes: ["special"],
  255. defaultFilterType: "special"
  256. },
  257. filterType: { name: "special", displayName: "Special" }
  258. }
  259. ],
  260. appliedFilterOperator: "or"
  261. };
  262. router.push({
  263. path: `/admin/youtube/videos`,
  264. query: { filter: JSON.stringify(filter) }
  265. });
  266. };
  267. const resetCreateImport = () => {
  268. createImport.value = {
  269. stage: 2,
  270. importMethod: "youtube",
  271. youtubeUrl: "",
  272. isImportingOnlyMusic: false
  273. };
  274. };
  275. const importFromYoutube = () => {
  276. if (!createImport.value.youtubeUrl)
  277. return new Toast("Please enter a YouTube URL.");
  278. let id;
  279. let title;
  280. return socket.dispatch(
  281. "youtube.requestSetAdmin",
  282. createImport.value.youtubeUrl,
  283. createImport.value.isImportingOnlyMusic,
  284. true,
  285. {
  286. cb: () => {},
  287. onProgress: res => {
  288. if (res.status === "started") {
  289. id = res.id;
  290. title = res.title;
  291. }
  292. if (id)
  293. setJob({
  294. id,
  295. name: title,
  296. ...res
  297. });
  298. }
  299. }
  300. );
  301. };
  302. const submitCreateImport = stage => {
  303. if (stage === 2) {
  304. const playlistRegex = /[\\?&]list=([^&#]*)/;
  305. const channelRegex =
  306. /\.[\w]+\/(?:(?:channel\/(UC[0-9A-Za-z_-]{21}[AQgw]))|(?:user\/?([\w-]+))|(?:c\/?([\w-]+))|(?:\/?([\w-]+)))/;
  307. if (
  308. playlistRegex.exec(createImport.value.youtubeUrl) ||
  309. channelRegex.exec(createImport.value.youtubeUrl)
  310. )
  311. importFromYoutube();
  312. else
  313. return new Toast({
  314. content: "Please enter a valid YouTube URL.",
  315. timeout: 4000
  316. });
  317. }
  318. if (stage === 3) resetCreateImport();
  319. else createImport.value.stage += 1;
  320. return createImport.value.stage;
  321. };
  322. // const prevCreateImport = stage => {
  323. // if (stage === 2) createImport.value.stage = 1;
  324. // };
  325. const editSongs = videos => {
  326. const songs = videos.map(youtubeId => ({ youtubeId }));
  327. if (songs.length === 1)
  328. openModal({ modal: "editSong", data: { song: songs[0] } });
  329. else openModal({ modal: "editSong", data: { songs } });
  330. };
  331. const importAlbum = youtubeIds => {
  332. socket.dispatch("songs.getSongsFromYoutubeIds", youtubeIds, res => {
  333. if (res.status === "success") {
  334. openModal({
  335. modal: "importAlbum",
  336. props: { songs: res.data.songs }
  337. });
  338. } else new Toast("Could not get songs.");
  339. });
  340. };
  341. const removeImportJob = jobId => {
  342. socket.dispatch("media.removeImportJobs", jobId, res => {
  343. new Toast(res.message);
  344. });
  345. };
  346. </script>
  347. <template>
  348. <div>
  349. <page-metadata title="Admin | Songs | Import" />
  350. <div class="admin-tab import-tab">
  351. <div class="card">
  352. <h1>Import Songs</h1>
  353. <p>Import songs from YouTube playlists or channels</p>
  354. </div>
  355. <div class="section-row">
  356. <div class="card left-section">
  357. <h4>Start New Import</h4>
  358. <hr class="section-horizontal-rule" />
  359. <div v-if="false && createImport.stage === 1" class="stage">
  360. <label class="label">Import Method</label>
  361. <div class="control is-expanded select">
  362. <select v-model="createImport.importMethod">
  363. <option value="youtube">YouTube</option>
  364. </select>
  365. </div>
  366. <div class="control is-expanded">
  367. <button
  368. class="button is-primary"
  369. @click.prevent="submitCreateImport(1)"
  370. >
  371. <i class="material-icons">navigate_next</i>
  372. Next
  373. </button>
  374. </div>
  375. </div>
  376. <div
  377. v-else-if="
  378. createImport.stage === 2 &&
  379. createImport.importMethod === 'youtube'
  380. "
  381. class="stage"
  382. >
  383. <label class="label"
  384. >YouTube URL
  385. <info-icon
  386. tooltip="YouTube playlist or channel URLs may be provided"
  387. />
  388. </label>
  389. <div class="control is-expanded">
  390. <input
  391. class="input"
  392. type="text"
  393. placeholder="YouTube Playlist or Channel URL"
  394. v-model="createImport.youtubeUrl"
  395. />
  396. </div>
  397. <div class="control is-expanded checkbox-control">
  398. <label class="switch">
  399. <input
  400. type="checkbox"
  401. id="import-music-only"
  402. v-model="createImport.isImportingOnlyMusic"
  403. />
  404. <span class="slider round"></span>
  405. </label>
  406. <label class="label" for="import-music-only">
  407. Import Music Only
  408. <info-icon
  409. tooltip="Only import videos from YouTube identified as music"
  410. @click.prevent
  411. />
  412. </label>
  413. </div>
  414. <div class="control is-expanded">
  415. <button
  416. class="control is-expanded button is-primary"
  417. @click.prevent="submitCreateImport(2)"
  418. >
  419. <i class="material-icons icon-with-button"
  420. >publish</i
  421. >
  422. Import
  423. </button>
  424. </div>
  425. </div>
  426. <div v-if="createImport.stage === 3" class="stage">
  427. <p class="has-text-centered import-started">
  428. Import Started
  429. </p>
  430. <div class="control is-expanded">
  431. <button
  432. class="button is-info"
  433. @click.prevent="submitCreateImport(3)"
  434. >
  435. <i class="material-icons icon-with-button"
  436. >restart_alt</i
  437. >
  438. Start Again
  439. </button>
  440. </div>
  441. </div>
  442. </div>
  443. <div class="card right-section">
  444. <h4>Manage Imports</h4>
  445. <hr class="section-horizontal-rule" />
  446. <advanced-table
  447. :column-default="columnDefault"
  448. :columns="columns"
  449. :filters="filters"
  450. :events="events"
  451. data-action="media.getImportJobs"
  452. name="admin-songs-import"
  453. :max-width="1060"
  454. >
  455. <template #column-options="slotProps">
  456. <div class="row-options">
  457. <button
  458. v-if="
  459. hasPermission(
  460. 'admin.view.youtubeVideos'
  461. )
  462. "
  463. class="button is-primary icon-with-button material-icons"
  464. @click="openAdvancedTable(slotProps.item)"
  465. :disabled="
  466. slotProps.item.removed ||
  467. slotProps.item.status !== 'success'
  468. "
  469. content="Manage imported videos"
  470. v-tippy
  471. >
  472. table_view
  473. </button>
  474. <button
  475. v-if="hasPermission('songs.update')"
  476. class="button is-primary icon-with-button material-icons"
  477. @click="
  478. editSongs(
  479. slotProps.item.response
  480. .successfulVideoIds
  481. )
  482. "
  483. :disabled="
  484. slotProps.item.removed ||
  485. slotProps.item.status !== 'success'
  486. "
  487. content="Create/edit song from videos"
  488. v-tippy
  489. >
  490. music_note
  491. </button>
  492. <button
  493. v-if="
  494. hasPermission('songs.update') &&
  495. hasPermission('apis.searchDiscogs')
  496. "
  497. class="button icon-with-button material-icons import-album-icon"
  498. @click="
  499. importAlbum(
  500. slotProps.item.response
  501. .successfulVideoIds
  502. )
  503. "
  504. :disabled="
  505. slotProps.item.removed ||
  506. slotProps.item.status !== 'success'
  507. "
  508. content="Import album from videos"
  509. v-tippy
  510. >
  511. album
  512. </button>
  513. <button
  514. v-if="
  515. hasPermission('media.removeImportJobs')
  516. "
  517. class="button is-danger icon-with-button material-icons"
  518. @click.prevent="
  519. openModal({
  520. modal: 'confirm',
  521. props: {
  522. message:
  523. 'Note: Removing an import will not remove any videos or songs.',
  524. onCompleted: () =>
  525. removeImportJob(
  526. slotProps.item._id
  527. )
  528. }
  529. })
  530. "
  531. :disabled="
  532. slotProps.item.removed ||
  533. slotProps.item.status === 'in-progress'
  534. "
  535. content="Remove Import"
  536. v-tippy
  537. >
  538. delete_forever
  539. </button>
  540. </div>
  541. </template>
  542. <template #column-type="slotProps">
  543. <span :title="slotProps.item.type">{{
  544. slotProps.item.type
  545. }}</span>
  546. </template>
  547. <template #column-requestedBy="slotProps">
  548. <user-link :user-id="slotProps.item.requestedBy" />
  549. </template>
  550. <template #column-requestedAt="slotProps">
  551. <span
  552. :title="
  553. new Date(
  554. slotProps.item.requestedAt
  555. ).toString()
  556. "
  557. >{{
  558. utils.getDateFormatted(
  559. slotProps.item.requestedAt
  560. )
  561. }}</span
  562. >
  563. </template>
  564. <template #column-successful="slotProps">
  565. <span :title="slotProps.item.response.successful">{{
  566. slotProps.item.response.successful
  567. }}</span>
  568. </template>
  569. <template #column-alreadyInDatabase="slotProps">
  570. <span
  571. :title="
  572. slotProps.item.response.alreadyInDatabase
  573. "
  574. >{{
  575. slotProps.item.response.alreadyInDatabase
  576. }}</span
  577. >
  578. </template>
  579. <template #column-failed="slotProps">
  580. <span :title="slotProps.item.response.failed">{{
  581. slotProps.item.response.failed
  582. }}</span>
  583. </template>
  584. <template #column-status="slotProps">
  585. <span :title="slotProps.item.status">{{
  586. slotProps.item.status
  587. }}</span>
  588. </template>
  589. <template #column-url="slotProps">
  590. <a
  591. :href="slotProps.item.query.url"
  592. target="_blank"
  593. >{{ slotProps.item.query.url }}</a
  594. >
  595. </template>
  596. <template #column-musicOnly="slotProps">
  597. <span :title="slotProps.item.query.musicOnly">{{
  598. slotProps.item.query.musicOnly
  599. }}</span>
  600. </template>
  601. <template #column-_id="slotProps">
  602. <span :title="slotProps.item._id">{{
  603. slotProps.item._id
  604. }}</span>
  605. </template>
  606. </advanced-table>
  607. </div>
  608. </div>
  609. </div>
  610. </div>
  611. </template>
  612. <style lang="less" scoped>
  613. .admin-tab.import-tab {
  614. .section-row {
  615. display: flex;
  616. flex-wrap: wrap;
  617. height: 100%;
  618. .card {
  619. max-height: 100%;
  620. overflow-y: auto;
  621. flex-grow: 1;
  622. .control.is-expanded {
  623. .button {
  624. width: 100%;
  625. }
  626. &:not(:last-of-type) {
  627. margin-bottom: 10px !important;
  628. }
  629. &:last-of-type {
  630. margin-bottom: 0 !important;
  631. }
  632. }
  633. .control.is-grouped > .button {
  634. &:not(:last-child) {
  635. border-radius: @border-radius 0 0 @border-radius;
  636. }
  637. &:last-child {
  638. border-radius: 0 @border-radius @border-radius 0;
  639. }
  640. }
  641. }
  642. .left-section {
  643. height: 100%;
  644. max-width: 400px;
  645. margin-right: 20px !important;
  646. .checkbox-control label.label {
  647. margin-left: 10px;
  648. }
  649. .import-started {
  650. font-size: 18px;
  651. font-weight: 600;
  652. margin-bottom: 10px;
  653. }
  654. }
  655. .right-section {
  656. max-width: calc(100% - 400px);
  657. .row-options .material-icons.import-album-icon {
  658. background-color: var(--purple);
  659. color: var(--white);
  660. border-color: var(--purple);
  661. font-size: 20px;
  662. }
  663. }
  664. @media screen and (max-width: 1200px) {
  665. .card {
  666. flex-basis: 100%;
  667. max-height: unset;
  668. &.left-section {
  669. max-width: unset;
  670. margin-right: 0 !important;
  671. margin-bottom: 10px !important;
  672. }
  673. &.right-section {
  674. max-width: unset;
  675. }
  676. }
  677. }
  678. }
  679. }
  680. </style>