Import.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691
  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. content="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. content="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 is-danger icon-with-button material-icons"
  142. @click.prevent="
  143. confirmAction({
  144. message:
  145. 'Note: Removing an import will not remove any videos or songs.',
  146. action: 'removeImportJob',
  147. params: slotProps.item._id
  148. })
  149. "
  150. :disabled="
  151. slotProps.item.removed ||
  152. slotProps.item.status === 'in-progress'
  153. "
  154. content="Remove Import"
  155. v-tippy
  156. >
  157. delete_forever
  158. </button>
  159. </div>
  160. </template>
  161. <template #column-type="slotProps">
  162. <span :title="slotProps.item.type">{{
  163. slotProps.item.type
  164. }}</span>
  165. </template>
  166. <template #column-requestedBy="slotProps">
  167. <user-link :user-id="slotProps.item.requestedBy" />
  168. </template>
  169. <template #column-requestedAt="slotProps">
  170. <span
  171. :title="new Date(slotProps.item.requestedAt)"
  172. >{{
  173. getDateFormatted(slotProps.item.requestedAt)
  174. }}</span
  175. >
  176. </template>
  177. <template #column-successful="slotProps">
  178. <span :title="slotProps.item.response.successful">{{
  179. slotProps.item.response.successful
  180. }}</span>
  181. </template>
  182. <template #column-alreadyInDatabase="slotProps">
  183. <span
  184. :title="
  185. slotProps.item.response.alreadyInDatabase
  186. "
  187. >{{
  188. slotProps.item.response.alreadyInDatabase
  189. }}</span
  190. >
  191. </template>
  192. <template #column-failed="slotProps">
  193. <span :title="slotProps.item.response.failed">{{
  194. slotProps.item.response.failed
  195. }}</span>
  196. </template>
  197. <template #column-status="slotProps">
  198. <span :title="slotProps.item.status">{{
  199. slotProps.item.status
  200. }}</span>
  201. </template>
  202. <template #column-url="slotProps">
  203. <a
  204. :href="slotProps.item.query.url"
  205. target="_blank"
  206. >{{ slotProps.item.query.url }}</a
  207. >
  208. </template>
  209. <template #column-musicOnly="slotProps">
  210. <span :title="slotProps.item.query.musicOnly">{{
  211. slotProps.item.query.musicOnly
  212. }}</span>
  213. </template>
  214. <template #column-_id="slotProps">
  215. <span :title="slotProps.item._id">{{
  216. slotProps.item._id
  217. }}</span>
  218. </template>
  219. </advanced-table>
  220. </div>
  221. </div>
  222. </div>
  223. </div>
  224. </template>
  225. <script>
  226. import { mapGetters, mapActions } from "vuex";
  227. import Toast from "toasters";
  228. import AdvancedTable from "@/components/AdvancedTable.vue";
  229. export default {
  230. components: {
  231. AdvancedTable
  232. },
  233. data() {
  234. return {
  235. createImport: {
  236. stage: 2,
  237. importMethod: "youtube",
  238. youtubeUrl:
  239. "https://www.youtube.com/playlist?list=PL3-sRm8xAzY9gpXTMGVHJWy_FMD67NBed",
  240. isImportingOnlyMusic: false
  241. },
  242. columnDefault: {
  243. sortable: true,
  244. hidable: true,
  245. defaultVisibility: "shown",
  246. draggable: true,
  247. resizable: true,
  248. minWidth: 200,
  249. maxWidth: 600
  250. },
  251. columns: [
  252. {
  253. name: "options",
  254. displayName: "Options",
  255. properties: ["_id", "status"],
  256. sortable: false,
  257. hidable: false,
  258. resizable: false,
  259. minWidth: 129,
  260. defaultWidth: 129
  261. },
  262. {
  263. name: "type",
  264. displayName: "Type",
  265. properties: ["type"],
  266. sortProperty: "type",
  267. minWidth: 120,
  268. defaultWidth: 120
  269. },
  270. {
  271. name: "requestedBy",
  272. displayName: "Requested By",
  273. properties: ["requestedBy"],
  274. sortProperty: "requestedBy"
  275. },
  276. {
  277. name: "requestedAt",
  278. displayName: "Requested At",
  279. properties: ["requestedAt"],
  280. sortProperty: "requestedAt"
  281. },
  282. {
  283. name: "successful",
  284. displayName: "Successful",
  285. properties: ["response"],
  286. sortProperty: "response.successful",
  287. minWidth: 120,
  288. defaultWidth: 120
  289. },
  290. {
  291. name: "alreadyInDatabase",
  292. displayName: "Existing",
  293. properties: ["response"],
  294. sortProperty: "response.alreadyInDatabase",
  295. minWidth: 120,
  296. defaultWidth: 120
  297. },
  298. {
  299. name: "failed",
  300. displayName: "Failed",
  301. properties: ["response"],
  302. sortProperty: "response.failed",
  303. minWidth: 120,
  304. defaultWidth: 120
  305. },
  306. {
  307. name: "status",
  308. displayName: "Status",
  309. properties: ["status"],
  310. sortProperty: "status",
  311. defaultVisibility: "hidden"
  312. },
  313. {
  314. name: "url",
  315. displayName: "URL",
  316. properties: ["query.url"],
  317. sortProperty: "query.url"
  318. },
  319. {
  320. name: "musicOnly",
  321. displayName: "Music Only",
  322. properties: ["query.musicOnly"],
  323. sortProperty: "query.musicOnly",
  324. minWidth: 120,
  325. defaultWidth: 120
  326. },
  327. {
  328. name: "_id",
  329. displayName: "Import ID",
  330. properties: ["_id"],
  331. sortProperty: "_id",
  332. minWidth: 215,
  333. defaultWidth: 215,
  334. defaultVisibility: "hidden"
  335. }
  336. ],
  337. filters: [
  338. {
  339. name: "_id",
  340. displayName: "Import ID",
  341. property: "_id",
  342. filterTypes: ["exact"],
  343. defaultFilterType: "exact"
  344. },
  345. {
  346. name: "type",
  347. displayName: "Type",
  348. property: "type",
  349. filterTypes: ["exact"],
  350. defaultFilterType: "exact",
  351. dropdown: [["youtube", "YouTube"]]
  352. },
  353. {
  354. name: "requestedBy",
  355. displayName: "Requested By",
  356. property: "requestedBy",
  357. filterTypes: ["contains", "exact", "regex"],
  358. defaultFilterType: "contains"
  359. },
  360. {
  361. name: "requestedAt",
  362. displayName: "Requested At",
  363. property: "requestedAt",
  364. filterTypes: ["datetimeBefore", "datetimeAfter"],
  365. defaultFilterType: "datetimeBefore"
  366. },
  367. {
  368. name: "response.successful",
  369. displayName: "Successful",
  370. property: "response.successful",
  371. filterTypes: [
  372. "numberLesserEqual",
  373. "numberLesser",
  374. "numberGreater",
  375. "numberGreaterEqual",
  376. "numberEquals"
  377. ],
  378. defaultFilterType: "numberLesser"
  379. },
  380. {
  381. name: "response.alreadyInDatabase",
  382. displayName: "Existing",
  383. property: "response.alreadyInDatabase",
  384. filterTypes: [
  385. "numberLesserEqual",
  386. "numberLesser",
  387. "numberGreater",
  388. "numberGreaterEqual",
  389. "numberEquals"
  390. ],
  391. defaultFilterType: "numberLesser"
  392. },
  393. {
  394. name: "response.failed",
  395. displayName: "Failed",
  396. property: "response.failed",
  397. filterTypes: [
  398. "numberLesserEqual",
  399. "numberLesser",
  400. "numberGreater",
  401. "numberGreaterEqual",
  402. "numberEquals"
  403. ],
  404. defaultFilterType: "numberLesser"
  405. },
  406. {
  407. name: "status",
  408. displayName: "Status",
  409. property: "status",
  410. filterTypes: ["contains", "exact", "regex"],
  411. defaultFilterType: "contains"
  412. },
  413. {
  414. name: "url",
  415. displayName: "URL",
  416. property: "query.url",
  417. filterTypes: ["contains", "exact", "regex"],
  418. defaultFilterType: "contains"
  419. },
  420. {
  421. name: "musicOnly",
  422. displayName: "Music Only",
  423. property: "query.musicOnly",
  424. filterTypes: ["exact"],
  425. defaultFilterType: "exact",
  426. dropdown: [
  427. [true, "True"],
  428. [false, "False"]
  429. ]
  430. },
  431. {
  432. name: "status",
  433. displayName: "Status",
  434. property: "status",
  435. filterTypes: ["exact"],
  436. defaultFilterType: "exact",
  437. dropdown: [
  438. ["success", "Success"],
  439. ["in-progress", "In Progress"],
  440. ["failed", "Failed"]
  441. ]
  442. }
  443. ],
  444. events: {
  445. adminRoom: "import",
  446. updated: {
  447. event: "admin.importJob.updated",
  448. id: "importJob._id",
  449. item: "importJob"
  450. },
  451. removed: {
  452. event: "admin.importJob.removed",
  453. id: "jobId"
  454. }
  455. }
  456. };
  457. },
  458. computed: {
  459. ...mapGetters({
  460. socket: "websockets/getSocket"
  461. })
  462. },
  463. mounted() {
  464. // this.socket.dispatch("youtube.getRequestSetAdminLongJobs", {
  465. // cb: res => {
  466. // console.log(111, res);
  467. // },
  468. // onProgress: res => {
  469. // console.log(222, res);
  470. // }
  471. // });
  472. },
  473. methods: {
  474. openAdvancedTable(importJob) {
  475. const filter = {
  476. appliedFilters: [
  477. {
  478. data: importJob._id,
  479. filter: {
  480. name: "importJob",
  481. displayName: "Import%20job",
  482. property: "importJob",
  483. filterTypes: ["special"],
  484. defaultFilterType: "special"
  485. },
  486. filterType: { name: "special", displayName: "Special" }
  487. }
  488. ],
  489. appliedFilterOperator: "or"
  490. };
  491. this.$router.push({
  492. path: `/admin/youtube/videos`,
  493. query: { filter: JSON.stringify(filter) }
  494. });
  495. },
  496. submitCreateImport(stage) {
  497. if (stage === 2) {
  498. const playlistRegex = /[\\?&]list=([^&#]*)/;
  499. const channelRegex =
  500. /\.[\w]+\/(?:(?:channel\/(UC[0-9A-Za-z_-]{21}[AQgw]))|(?:user\/?([\w-]+))|(?:c\/?([\w-]+))|(?:\/?([\w-]+)))/;
  501. if (
  502. playlistRegex.exec(this.createImport.youtubeUrl) ||
  503. channelRegex.exec(this.createImport.youtubeUrl)
  504. )
  505. this.importFromYoutube();
  506. else
  507. return new Toast({
  508. content: "Please enter a valid YouTube URL.",
  509. timeout: 4000
  510. });
  511. }
  512. if (stage === 3) this.resetCreateImport();
  513. else this.createImport.stage += 1;
  514. return this.createImport.stage;
  515. },
  516. resetCreateImport() {
  517. this.createImport = {
  518. stage: 2,
  519. importMethod: "youtube",
  520. youtubeUrl:
  521. "https://www.youtube.com/channel/UCio_FVgKVgqcHrRiXDpnqbw",
  522. isImportingOnlyMusic: false
  523. };
  524. },
  525. prevCreateImport(stage) {
  526. if (stage === 2) this.createImport.stage = 1;
  527. },
  528. importFromYoutube() {
  529. if (!this.createImport.youtubeUrl)
  530. return new Toast("Please enter a YouTube URL.");
  531. let id;
  532. let title;
  533. return this.socket.dispatch(
  534. "youtube.requestSetAdmin",
  535. this.createImport.youtubeUrl,
  536. this.createImport.isImportingOnlyMusic,
  537. true,
  538. {
  539. cb: () => {},
  540. onProgress: res => {
  541. if (res.status === "started") {
  542. id = res.id;
  543. title = res.title;
  544. }
  545. if (id)
  546. this.setJob({
  547. id,
  548. name: title,
  549. ...res
  550. });
  551. }
  552. }
  553. );
  554. },
  555. getDateFormatted(createdAt) {
  556. const date = new Date(createdAt);
  557. const year = date.getFullYear();
  558. const month = `${date.getMonth() + 1}`.padStart(2, 0);
  559. const day = `${date.getDate()}`.padStart(2, 0);
  560. const hour = `${date.getHours()}`.padStart(2, 0);
  561. const minute = `${date.getMinutes()}`.padStart(2, 0);
  562. return `${year}-${month}-${day} ${hour}:${minute}`;
  563. },
  564. editSongs(videos) {
  565. const songs = videos.map(youtubeId => ({ youtubeId }));
  566. if (songs.length === 1)
  567. this.openModal({ modal: "editSong", data: { song: songs[0] } });
  568. else this.openModal({ modal: "editSongs", data: { songs } });
  569. },
  570. removeImportJob(jobId) {
  571. this.socket.dispatch("media.removeImportJobs", jobId, res => {
  572. new Toast(res.message);
  573. });
  574. },
  575. confirmAction({ message, action, params }) {
  576. this.openModal({
  577. modal: "confirm",
  578. data: {
  579. message,
  580. action,
  581. params,
  582. onCompleted: this.handleConfirmed
  583. }
  584. });
  585. },
  586. handleConfirmed({ action, params }) {
  587. if (typeof this[action] === "function") {
  588. if (params) this[action](params);
  589. else this[action]();
  590. }
  591. },
  592. ...mapActions("modalVisibility", ["openModal"]),
  593. ...mapActions("longJobs", ["setJob"])
  594. }
  595. };
  596. </script>
  597. <style lang="less" scoped>
  598. .admin-tab.import-tab {
  599. .section-row {
  600. display: flex;
  601. flex-wrap: wrap;
  602. height: 100%;
  603. .card {
  604. max-height: 100%;
  605. overflow-y: auto;
  606. flex-grow: 1;
  607. .control.is-expanded {
  608. .button {
  609. width: 100%;
  610. }
  611. &:not(:last-of-type) {
  612. margin-bottom: 10px !important;
  613. }
  614. &:last-of-type {
  615. margin-bottom: 0 !important;
  616. }
  617. }
  618. .control.is-grouped > .button {
  619. &:not(:last-child) {
  620. border-radius: @border-radius 0 0 @border-radius;
  621. }
  622. &:last-child {
  623. border-radius: 0 @border-radius @border-radius 0;
  624. }
  625. }
  626. }
  627. .left-section {
  628. height: 100%;
  629. max-width: 400px;
  630. margin-right: 20px !important;
  631. .checkbox-control label.label {
  632. margin-left: 10px;
  633. }
  634. .import-started {
  635. font-size: 18px;
  636. font-weight: 600;
  637. margin-bottom: 10px;
  638. }
  639. }
  640. .right-section {
  641. max-width: calc(100% - 400px);
  642. }
  643. @media screen and (max-width: 1200px) {
  644. .card {
  645. flex-basis: 100%;
  646. max-height: unset;
  647. &.left-section {
  648. max-width: unset;
  649. margin-right: 0 !important;
  650. margin-bottom: 10px !important;
  651. }
  652. &.right-section {
  653. max-width: unset;
  654. }
  655. }
  656. }
  657. }
  658. }
  659. </style>