Import.vue 16 KB

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