Import.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681
  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 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. methods: {
  464. openAdvancedTable(importJob) {
  465. const filter = {
  466. appliedFilters: [
  467. {
  468. data: importJob._id,
  469. filter: {
  470. name: "importJob",
  471. displayName: "Import Job",
  472. property: "importJob",
  473. filterTypes: ["special"],
  474. defaultFilterType: "special"
  475. },
  476. filterType: { name: "special", displayName: "Special" }
  477. }
  478. ],
  479. appliedFilterOperator: "or"
  480. };
  481. this.$router.push({
  482. path: `/admin/youtube/videos`,
  483. query: { filter: JSON.stringify(filter) }
  484. });
  485. },
  486. submitCreateImport(stage) {
  487. if (stage === 2) {
  488. const playlistRegex = /[\\?&]list=([^&#]*)/;
  489. const channelRegex =
  490. /\.[\w]+\/(?:(?:channel\/(UC[0-9A-Za-z_-]{21}[AQgw]))|(?:user\/?([\w-]+))|(?:c\/?([\w-]+))|(?:\/?([\w-]+)))/;
  491. if (
  492. playlistRegex.exec(this.createImport.youtubeUrl) ||
  493. channelRegex.exec(this.createImport.youtubeUrl)
  494. )
  495. this.importFromYoutube();
  496. else
  497. return new Toast({
  498. content: "Please enter a valid YouTube URL.",
  499. timeout: 4000
  500. });
  501. }
  502. if (stage === 3) this.resetCreateImport();
  503. else this.createImport.stage += 1;
  504. return this.createImport.stage;
  505. },
  506. resetCreateImport() {
  507. this.createImport = {
  508. stage: 2,
  509. importMethod: "youtube",
  510. youtubeUrl:
  511. "https://www.youtube.com/channel/UCio_FVgKVgqcHrRiXDpnqbw",
  512. isImportingOnlyMusic: false
  513. };
  514. },
  515. prevCreateImport(stage) {
  516. if (stage === 2) this.createImport.stage = 1;
  517. },
  518. importFromYoutube() {
  519. if (!this.createImport.youtubeUrl)
  520. return new Toast("Please enter a YouTube URL.");
  521. let id;
  522. let title;
  523. return this.socket.dispatch(
  524. "youtube.requestSetAdmin",
  525. this.createImport.youtubeUrl,
  526. this.createImport.isImportingOnlyMusic,
  527. true,
  528. {
  529. cb: () => {},
  530. onProgress: res => {
  531. if (res.status === "started") {
  532. id = res.id;
  533. title = res.title;
  534. }
  535. if (id)
  536. this.setJob({
  537. id,
  538. name: title,
  539. ...res
  540. });
  541. }
  542. }
  543. );
  544. },
  545. getDateFormatted(createdAt) {
  546. const date = new Date(createdAt);
  547. const year = date.getFullYear();
  548. const month = `${date.getMonth() + 1}`.padStart(2, 0);
  549. const day = `${date.getDate()}`.padStart(2, 0);
  550. const hour = `${date.getHours()}`.padStart(2, 0);
  551. const minute = `${date.getMinutes()}`.padStart(2, 0);
  552. return `${year}-${month}-${day} ${hour}:${minute}`;
  553. },
  554. editSongs(videos) {
  555. const songs = videos.map(youtubeId => ({ youtubeId }));
  556. if (songs.length === 1)
  557. this.openModal({ modal: "editSong", data: { song: songs[0] } });
  558. else this.openModal({ modal: "editSongs", data: { songs } });
  559. },
  560. removeImportJob(jobId) {
  561. this.socket.dispatch("media.removeImportJobs", jobId, res => {
  562. new Toast(res.message);
  563. });
  564. },
  565. confirmAction({ message, action, params }) {
  566. this.openModal({
  567. modal: "confirm",
  568. data: {
  569. message,
  570. action,
  571. params,
  572. onCompleted: this.handleConfirmed
  573. }
  574. });
  575. },
  576. handleConfirmed({ action, params }) {
  577. if (typeof this[action] === "function") {
  578. if (params) this[action](params);
  579. else this[action]();
  580. }
  581. },
  582. ...mapActions("modalVisibility", ["openModal"]),
  583. ...mapActions("longJobs", ["setJob"])
  584. }
  585. };
  586. </script>
  587. <style lang="less" scoped>
  588. .admin-tab.import-tab {
  589. .section-row {
  590. display: flex;
  591. flex-wrap: wrap;
  592. height: 100%;
  593. .card {
  594. max-height: 100%;
  595. overflow-y: auto;
  596. flex-grow: 1;
  597. .control.is-expanded {
  598. .button {
  599. width: 100%;
  600. }
  601. &:not(:last-of-type) {
  602. margin-bottom: 10px !important;
  603. }
  604. &:last-of-type {
  605. margin-bottom: 0 !important;
  606. }
  607. }
  608. .control.is-grouped > .button {
  609. &:not(:last-child) {
  610. border-radius: @border-radius 0 0 @border-radius;
  611. }
  612. &:last-child {
  613. border-radius: 0 @border-radius @border-radius 0;
  614. }
  615. }
  616. }
  617. .left-section {
  618. height: 100%;
  619. max-width: 400px;
  620. margin-right: 20px !important;
  621. .checkbox-control label.label {
  622. margin-left: 10px;
  623. }
  624. .import-started {
  625. font-size: 18px;
  626. font-weight: 600;
  627. margin-bottom: 10px;
  628. }
  629. }
  630. .right-section {
  631. max-width: calc(100% - 400px);
  632. }
  633. @media screen and (max-width: 1200px) {
  634. .card {
  635. flex-basis: 100%;
  636. max-height: unset;
  637. &.left-section {
  638. max-width: unset;
  639. margin-right: 0 !important;
  640. margin-bottom: 10px !important;
  641. }
  642. &.right-section {
  643. max-width: unset;
  644. }
  645. }
  646. }
  647. }
  648. }
  649. </style>