Import.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717
  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. isImportingOnlyMusic: false
  257. },
  258. columnDefault: {
  259. sortable: true,
  260. hidable: true,
  261. defaultVisibility: "shown",
  262. draggable: true,
  263. resizable: true,
  264. minWidth: 200,
  265. maxWidth: 600
  266. },
  267. columns: [
  268. {
  269. name: "options",
  270. displayName: "Options",
  271. properties: ["_id", "status"],
  272. sortable: false,
  273. hidable: false,
  274. resizable: false,
  275. minWidth: 160,
  276. defaultWidth: 160
  277. },
  278. {
  279. name: "type",
  280. displayName: "Type",
  281. properties: ["type"],
  282. sortProperty: "type",
  283. minWidth: 120,
  284. defaultWidth: 120
  285. },
  286. {
  287. name: "requestedBy",
  288. displayName: "Requested By",
  289. properties: ["requestedBy"],
  290. sortProperty: "requestedBy"
  291. },
  292. {
  293. name: "requestedAt",
  294. displayName: "Requested At",
  295. properties: ["requestedAt"],
  296. sortProperty: "requestedAt"
  297. },
  298. {
  299. name: "successful",
  300. displayName: "Successful",
  301. properties: ["response"],
  302. sortProperty: "response.successful",
  303. minWidth: 120,
  304. defaultWidth: 120
  305. },
  306. {
  307. name: "alreadyInDatabase",
  308. displayName: "Existing",
  309. properties: ["response"],
  310. sortProperty: "response.alreadyInDatabase",
  311. minWidth: 120,
  312. defaultWidth: 120
  313. },
  314. {
  315. name: "failed",
  316. displayName: "Failed",
  317. properties: ["response"],
  318. sortProperty: "response.failed",
  319. minWidth: 120,
  320. defaultWidth: 120
  321. },
  322. {
  323. name: "status",
  324. displayName: "Status",
  325. properties: ["status"],
  326. sortProperty: "status",
  327. defaultVisibility: "hidden"
  328. },
  329. {
  330. name: "url",
  331. displayName: "URL",
  332. properties: ["query.url"],
  333. sortProperty: "query.url"
  334. },
  335. {
  336. name: "musicOnly",
  337. displayName: "Music Only",
  338. properties: ["query.musicOnly"],
  339. sortProperty: "query.musicOnly",
  340. minWidth: 120,
  341. defaultWidth: 120
  342. },
  343. {
  344. name: "_id",
  345. displayName: "Import ID",
  346. properties: ["_id"],
  347. sortProperty: "_id",
  348. minWidth: 215,
  349. defaultWidth: 215,
  350. defaultVisibility: "hidden"
  351. }
  352. ],
  353. filters: [
  354. {
  355. name: "_id",
  356. displayName: "Import ID",
  357. property: "_id",
  358. filterTypes: ["exact"],
  359. defaultFilterType: "exact"
  360. },
  361. {
  362. name: "type",
  363. displayName: "Type",
  364. property: "type",
  365. filterTypes: ["exact"],
  366. defaultFilterType: "exact",
  367. dropdown: [["youtube", "YouTube"]]
  368. },
  369. {
  370. name: "requestedBy",
  371. displayName: "Requested By",
  372. property: "requestedBy",
  373. filterTypes: ["contains", "exact", "regex"],
  374. defaultFilterType: "contains"
  375. },
  376. {
  377. name: "requestedAt",
  378. displayName: "Requested At",
  379. property: "requestedAt",
  380. filterTypes: ["datetimeBefore", "datetimeAfter"],
  381. defaultFilterType: "datetimeBefore"
  382. },
  383. {
  384. name: "response.successful",
  385. displayName: "Successful",
  386. property: "response.successful",
  387. filterTypes: [
  388. "numberLesserEqual",
  389. "numberLesser",
  390. "numberGreater",
  391. "numberGreaterEqual",
  392. "numberEquals"
  393. ],
  394. defaultFilterType: "numberLesser"
  395. },
  396. {
  397. name: "response.alreadyInDatabase",
  398. displayName: "Existing",
  399. property: "response.alreadyInDatabase",
  400. filterTypes: [
  401. "numberLesserEqual",
  402. "numberLesser",
  403. "numberGreater",
  404. "numberGreaterEqual",
  405. "numberEquals"
  406. ],
  407. defaultFilterType: "numberLesser"
  408. },
  409. {
  410. name: "response.failed",
  411. displayName: "Failed",
  412. property: "response.failed",
  413. filterTypes: [
  414. "numberLesserEqual",
  415. "numberLesser",
  416. "numberGreater",
  417. "numberGreaterEqual",
  418. "numberEquals"
  419. ],
  420. defaultFilterType: "numberLesser"
  421. },
  422. {
  423. name: "status",
  424. displayName: "Status",
  425. property: "status",
  426. filterTypes: ["contains", "exact", "regex"],
  427. defaultFilterType: "contains"
  428. },
  429. {
  430. name: "url",
  431. displayName: "URL",
  432. property: "query.url",
  433. filterTypes: ["contains", "exact", "regex"],
  434. defaultFilterType: "contains"
  435. },
  436. {
  437. name: "musicOnly",
  438. displayName: "Music Only",
  439. property: "query.musicOnly",
  440. filterTypes: ["exact"],
  441. defaultFilterType: "exact",
  442. dropdown: [
  443. [true, "True"],
  444. [false, "False"]
  445. ]
  446. },
  447. {
  448. name: "status",
  449. displayName: "Status",
  450. property: "status",
  451. filterTypes: ["exact"],
  452. defaultFilterType: "exact",
  453. dropdown: [
  454. ["success", "Success"],
  455. ["in-progress", "In Progress"],
  456. ["failed", "Failed"]
  457. ]
  458. }
  459. ],
  460. events: {
  461. adminRoom: "import",
  462. updated: {
  463. event: "admin.importJob.updated",
  464. id: "importJob._id",
  465. item: "importJob"
  466. },
  467. removed: {
  468. event: "admin.importJob.removed",
  469. id: "jobId"
  470. }
  471. }
  472. };
  473. },
  474. computed: {
  475. ...mapGetters({
  476. socket: "websockets/getSocket"
  477. })
  478. },
  479. methods: {
  480. openAdvancedTable(importJob) {
  481. const filter = {
  482. appliedFilters: [
  483. {
  484. data: importJob._id,
  485. filter: {
  486. name: "importJob",
  487. displayName: "Import Job",
  488. property: "importJob",
  489. filterTypes: ["special"],
  490. defaultFilterType: "special"
  491. },
  492. filterType: { name: "special", displayName: "Special" }
  493. }
  494. ],
  495. appliedFilterOperator: "or"
  496. };
  497. this.$router.push({
  498. path: `/admin/youtube/videos`,
  499. query: { filter: JSON.stringify(filter) }
  500. });
  501. },
  502. submitCreateImport(stage) {
  503. if (stage === 2) {
  504. const playlistRegex = /[\\?&]list=([^&#]*)/;
  505. const channelRegex =
  506. /\.[\w]+\/(?:(?:channel\/(UC[0-9A-Za-z_-]{21}[AQgw]))|(?:user\/?([\w-]+))|(?:c\/?([\w-]+))|(?:\/?([\w-]+)))/;
  507. if (
  508. playlistRegex.exec(this.createImport.youtubeUrl) ||
  509. channelRegex.exec(this.createImport.youtubeUrl)
  510. )
  511. this.importFromYoutube();
  512. else
  513. return new Toast({
  514. content: "Please enter a valid YouTube URL.",
  515. timeout: 4000
  516. });
  517. }
  518. if (stage === 3) this.resetCreateImport();
  519. else this.createImport.stage += 1;
  520. return this.createImport.stage;
  521. },
  522. resetCreateImport() {
  523. this.createImport = {
  524. stage: 2,
  525. importMethod: "youtube",
  526. youtubeUrl: "",
  527. isImportingOnlyMusic: false
  528. };
  529. },
  530. prevCreateImport(stage) {
  531. if (stage === 2) this.createImport.stage = 1;
  532. },
  533. importFromYoutube() {
  534. if (!this.createImport.youtubeUrl)
  535. return new Toast("Please enter a YouTube URL.");
  536. let id;
  537. let title;
  538. return this.socket.dispatch(
  539. "youtube.requestSetAdmin",
  540. this.createImport.youtubeUrl,
  541. this.createImport.isImportingOnlyMusic,
  542. true,
  543. {
  544. cb: () => {},
  545. onProgress: res => {
  546. if (res.status === "started") {
  547. id = res.id;
  548. title = res.title;
  549. }
  550. if (id)
  551. this.setJob({
  552. id,
  553. name: title,
  554. ...res
  555. });
  556. }
  557. }
  558. );
  559. },
  560. getDateFormatted(createdAt) {
  561. const date = new Date(createdAt);
  562. const year = date.getFullYear();
  563. const month = `${date.getMonth() + 1}`.padStart(2, 0);
  564. const day = `${date.getDate()}`.padStart(2, 0);
  565. const hour = `${date.getHours()}`.padStart(2, 0);
  566. const minute = `${date.getMinutes()}`.padStart(2, 0);
  567. return `${year}-${month}-${day} ${hour}:${minute}`;
  568. },
  569. editSongs(videos) {
  570. const songs = videos.map(youtubeId => ({ youtubeId }));
  571. if (songs.length === 1)
  572. this.openModal({ modal: "editSong", data: { song: songs[0] } });
  573. else this.openModal({ modal: "editSongs", data: { songs } });
  574. },
  575. importAlbum(youtubeIds) {
  576. this.socket.dispatch(
  577. "songs.getSongsFromYoutubeIds",
  578. youtubeIds,
  579. res => {
  580. if (res.status === "success") {
  581. this.openModal({
  582. modal: "importAlbum",
  583. data: { songs: res.data.songs }
  584. });
  585. } else new Toast("Could not get songs.");
  586. }
  587. );
  588. },
  589. removeImportJob(jobId) {
  590. this.socket.dispatch("media.removeImportJobs", jobId, res => {
  591. new Toast(res.message);
  592. });
  593. },
  594. confirmAction({ message, action, params }) {
  595. this.openModal({
  596. modal: "confirm",
  597. data: {
  598. message,
  599. action,
  600. params,
  601. onCompleted: this.handleConfirmed
  602. }
  603. });
  604. },
  605. handleConfirmed({ action, params }) {
  606. if (typeof this[action] === "function") {
  607. if (params) this[action](params);
  608. else this[action]();
  609. }
  610. },
  611. ...mapActions("modalVisibility", ["openModal"]),
  612. ...mapActions("longJobs", ["setJob"])
  613. }
  614. };
  615. </script>
  616. <style lang="less" scoped>
  617. .admin-tab.import-tab {
  618. .section-row {
  619. display: flex;
  620. flex-wrap: wrap;
  621. height: 100%;
  622. .card {
  623. max-height: 100%;
  624. overflow-y: auto;
  625. flex-grow: 1;
  626. .control.is-expanded {
  627. .button {
  628. width: 100%;
  629. }
  630. &:not(:last-of-type) {
  631. margin-bottom: 10px !important;
  632. }
  633. &:last-of-type {
  634. margin-bottom: 0 !important;
  635. }
  636. }
  637. .control.is-grouped > .button {
  638. &:not(:last-child) {
  639. border-radius: @border-radius 0 0 @border-radius;
  640. }
  641. &:last-child {
  642. border-radius: 0 @border-radius @border-radius 0;
  643. }
  644. }
  645. }
  646. .left-section {
  647. height: 100%;
  648. max-width: 400px;
  649. margin-right: 20px !important;
  650. .checkbox-control label.label {
  651. margin-left: 10px;
  652. }
  653. .import-started {
  654. font-size: 18px;
  655. font-weight: 600;
  656. margin-bottom: 10px;
  657. }
  658. }
  659. .right-section {
  660. max-width: calc(100% - 400px);
  661. .row-options .material-icons.import-album-icon {
  662. background-color: var(--purple);
  663. color: var(--white);
  664. border-color: var(--purple);
  665. font-size: 20px;
  666. }
  667. }
  668. @media screen and (max-width: 1200px) {
  669. .card {
  670. flex-basis: 100%;
  671. max-height: unset;
  672. &.left-section {
  673. max-width: unset;
  674. margin-right: 0 !important;
  675. margin-bottom: 10px !important;
  676. }
  677. &.right-section {
  678. max-width: unset;
  679. }
  680. }
  681. }
  682. }
  683. }
  684. </style>