Import.vue 17 KB

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