Import.vue 16 KB

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