Import.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759
  1. <script setup lang="ts">
  2. import { defineAsyncComponent, ref } from "vue";
  3. import { useRouter } from "vue-router";
  4. import Toast from "toasters";
  5. import { useWebsocketsStore } from "@/stores/websockets";
  6. import { useLongJobsStore } from "@/stores/longJobs";
  7. import { useModalsStore } from "@/stores/modals";
  8. import { useUserAuthStore } from "@/stores/userAuth";
  9. import { TableColumn, TableFilter, TableEvents } from "@/types/advancedTable";
  10. import utils from "@/utils";
  11. // TODO make this page support Spotify
  12. const AdvancedTable = defineAsyncComponent(
  13. () => import("@/components/AdvancedTable.vue")
  14. );
  15. const InfoIcon = defineAsyncComponent(
  16. () => import("@/components/InfoIcon.vue")
  17. );
  18. const UserLink = defineAsyncComponent(
  19. () => import("@/components/UserLink.vue")
  20. );
  21. const router = useRouter();
  22. const { socket } = useWebsocketsStore();
  23. const createImport = ref({
  24. stage: 2,
  25. importMethod: "youtube",
  26. youtubeUrl: "",
  27. isImportingOnlyMusic: false
  28. });
  29. const columnDefault = ref<TableColumn>({
  30. sortable: true,
  31. hidable: true,
  32. defaultVisibility: "shown",
  33. draggable: true,
  34. resizable: true,
  35. minWidth: 200,
  36. maxWidth: 600
  37. });
  38. const columns = ref<TableColumn[]>([
  39. {
  40. name: "options",
  41. displayName: "Options",
  42. properties: ["_id", "status"],
  43. sortable: false,
  44. hidable: false,
  45. resizable: false,
  46. minWidth: 200,
  47. defaultWidth: 200
  48. },
  49. {
  50. name: "type",
  51. displayName: "Type",
  52. properties: ["type"],
  53. sortProperty: "type",
  54. minWidth: 120,
  55. defaultWidth: 120
  56. },
  57. {
  58. name: "requestedBy",
  59. displayName: "Requested By",
  60. properties: ["requestedBy"],
  61. sortProperty: "requestedBy"
  62. },
  63. {
  64. name: "requestedAt",
  65. displayName: "Requested At",
  66. properties: ["requestedAt"],
  67. sortProperty: "requestedAt"
  68. },
  69. {
  70. name: "successful",
  71. displayName: "Successful",
  72. properties: ["response"],
  73. sortProperty: "response.successful",
  74. minWidth: 120,
  75. defaultWidth: 120
  76. },
  77. {
  78. name: "alreadyInDatabase",
  79. displayName: "Existing",
  80. properties: ["response"],
  81. sortProperty: "response.alreadyInDatabase",
  82. minWidth: 120,
  83. defaultWidth: 120
  84. },
  85. {
  86. name: "failed",
  87. displayName: "Failed",
  88. properties: ["response"],
  89. sortProperty: "response.failed",
  90. minWidth: 120,
  91. defaultWidth: 120
  92. },
  93. {
  94. name: "status",
  95. displayName: "Status",
  96. properties: ["status"],
  97. sortProperty: "status",
  98. defaultVisibility: "hidden"
  99. },
  100. {
  101. name: "url",
  102. displayName: "URL",
  103. properties: ["query.url"],
  104. sortProperty: "query.url"
  105. },
  106. {
  107. name: "musicOnly",
  108. displayName: "Music Only",
  109. properties: ["query.musicOnly"],
  110. sortProperty: "query.musicOnly",
  111. minWidth: 120,
  112. defaultWidth: 120
  113. },
  114. {
  115. name: "_id",
  116. displayName: "Import ID",
  117. properties: ["_id"],
  118. sortProperty: "_id",
  119. minWidth: 215,
  120. defaultWidth: 215,
  121. defaultVisibility: "hidden"
  122. }
  123. ]);
  124. const filters = ref<TableFilter[]>([
  125. {
  126. name: "_id",
  127. displayName: "Import ID",
  128. property: "_id",
  129. filterTypes: ["exact"],
  130. defaultFilterType: "exact"
  131. },
  132. {
  133. name: "type",
  134. displayName: "Type",
  135. property: "type",
  136. filterTypes: ["exact"],
  137. defaultFilterType: "exact",
  138. dropdown: [["youtube", "YouTube"]]
  139. },
  140. {
  141. name: "requestedBy",
  142. displayName: "Requested By",
  143. property: "requestedBy",
  144. filterTypes: ["contains", "exact", "regex"],
  145. defaultFilterType: "contains"
  146. },
  147. {
  148. name: "requestedAt",
  149. displayName: "Requested At",
  150. property: "requestedAt",
  151. filterTypes: ["datetimeBefore", "datetimeAfter"],
  152. defaultFilterType: "datetimeBefore"
  153. },
  154. {
  155. name: "response.successful",
  156. displayName: "Successful",
  157. property: "response.successful",
  158. filterTypes: [
  159. "numberLesserEqual",
  160. "numberLesser",
  161. "numberGreater",
  162. "numberGreaterEqual",
  163. "numberEquals"
  164. ],
  165. defaultFilterType: "numberLesser"
  166. },
  167. {
  168. name: "response.alreadyInDatabase",
  169. displayName: "Existing",
  170. property: "response.alreadyInDatabase",
  171. filterTypes: [
  172. "numberLesserEqual",
  173. "numberLesser",
  174. "numberGreater",
  175. "numberGreaterEqual",
  176. "numberEquals"
  177. ],
  178. defaultFilterType: "numberLesser"
  179. },
  180. {
  181. name: "response.failed",
  182. displayName: "Failed",
  183. property: "response.failed",
  184. filterTypes: [
  185. "numberLesserEqual",
  186. "numberLesser",
  187. "numberGreater",
  188. "numberGreaterEqual",
  189. "numberEquals"
  190. ],
  191. defaultFilterType: "numberLesser"
  192. },
  193. {
  194. name: "status",
  195. displayName: "Status",
  196. property: "status",
  197. filterTypes: ["contains", "exact", "regex"],
  198. defaultFilterType: "contains"
  199. },
  200. {
  201. name: "url",
  202. displayName: "URL",
  203. property: "query.url",
  204. filterTypes: ["contains", "exact", "regex"],
  205. defaultFilterType: "contains"
  206. },
  207. {
  208. name: "musicOnly",
  209. displayName: "Music Only",
  210. property: "query.musicOnly",
  211. filterTypes: ["exact"],
  212. defaultFilterType: "exact",
  213. dropdown: [
  214. [true, "True"],
  215. [false, "False"]
  216. ]
  217. },
  218. {
  219. name: "status",
  220. displayName: "Status",
  221. property: "status",
  222. filterTypes: ["exact"],
  223. defaultFilterType: "exact",
  224. dropdown: [
  225. ["success", "Success"],
  226. ["in-progress", "In Progress"],
  227. ["failed", "Failed"]
  228. ]
  229. }
  230. ]);
  231. const events = ref<TableEvents>({
  232. adminRoom: "import",
  233. updated: {
  234. event: "admin.importJob.updated",
  235. id: "importJob._id",
  236. item: "importJob"
  237. },
  238. removed: {
  239. event: "admin.importJob.removed",
  240. id: "jobId"
  241. }
  242. });
  243. const { openModal } = useModalsStore();
  244. const { setJob } = useLongJobsStore();
  245. const { hasPermission } = useUserAuthStore();
  246. const openAdvancedTable = importJob => {
  247. const filter = {
  248. appliedFilters: [
  249. {
  250. data: importJob._id,
  251. filter: {
  252. name: "importJob",
  253. displayName: "Import Job",
  254. property: "importJob",
  255. filterTypes: ["special"],
  256. defaultFilterType: "special"
  257. },
  258. filterType: { name: "special", displayName: "Special" }
  259. }
  260. ],
  261. appliedFilterOperator: "or"
  262. };
  263. router.push({
  264. path: `/admin/youtube/videos`,
  265. query: { filter: JSON.stringify(filter) }
  266. });
  267. };
  268. const resetCreateImport = () => {
  269. createImport.value = {
  270. stage: 2,
  271. importMethod: "youtube",
  272. youtubeUrl: "",
  273. isImportingOnlyMusic: false
  274. };
  275. };
  276. const importFromYoutube = () => {
  277. if (!createImport.value.youtubeUrl)
  278. return new Toast("Please enter a YouTube URL.");
  279. let id;
  280. let title;
  281. return socket.dispatch(
  282. "youtube.requestSetAdmin",
  283. createImport.value.youtubeUrl,
  284. createImport.value.isImportingOnlyMusic,
  285. true,
  286. {
  287. cb: () => {},
  288. onProgress: res => {
  289. if (res.status === "started") {
  290. id = res.id;
  291. title = res.title;
  292. }
  293. if (id)
  294. setJob({
  295. id,
  296. name: title,
  297. ...res
  298. });
  299. }
  300. }
  301. );
  302. };
  303. const submitCreateImport = stage => {
  304. if (stage === 2) {
  305. const playlistRegex = /[\\?&]list=([^&#]*)/;
  306. const channelRegex =
  307. /\.[\w]+\/(?:(?:channel\/(UC[0-9A-Za-z_-]{21}[AQgw]))|(?:user\/?([\w-]+))|(?:c\/?([\w-]+))|(?:\/?([\w-]+)))/;
  308. if (
  309. playlistRegex.exec(createImport.value.youtubeUrl) ||
  310. channelRegex.exec(createImport.value.youtubeUrl)
  311. )
  312. importFromYoutube();
  313. else
  314. return new Toast({
  315. content: "Please enter a valid YouTube URL.",
  316. timeout: 4000
  317. });
  318. }
  319. if (stage === 3) resetCreateImport();
  320. else createImport.value.stage += 1;
  321. return createImport.value.stage;
  322. };
  323. // const prevCreateImport = stage => {
  324. // if (stage === 2) createImport.value.stage = 1;
  325. // };
  326. const editSongs = youtubeIds => {
  327. const mediaSources = youtubeIds.map(youtubeId => ({
  328. mediaSource: `youtube:${youtubeId}`
  329. }));
  330. console.log(59685486, youtubeIds, mediaSources);
  331. if (mediaSources.length === 1)
  332. openModal({ modal: "editSong", props: { song: mediaSources[0] } });
  333. else openModal({ modal: "editSong", props: { songs: mediaSources } });
  334. };
  335. const importAlbum = youtubeIds => {
  336. const mediaSources = youtubeIds.map(youtubeId => `youtube:${youtubeId}`);
  337. socket.dispatch("songs.getSongsFromMediaSources", mediaSources, res => {
  338. if (res.status === "success") {
  339. openModal({
  340. modal: "importAlbum",
  341. props: { songs: res.data.songs }
  342. });
  343. } else new Toast("Could not get songs.");
  344. });
  345. };
  346. const bulkEditPlaylist = youtubeIds => {
  347. const mediaSources = youtubeIds.map(youtubeId => `youtube:${youtubeId}`);
  348. openModal({
  349. modal: "bulkEditPlaylist",
  350. props: {
  351. mediaSources
  352. }
  353. });
  354. };
  355. const removeImportJob = jobId => {
  356. socket.dispatch("media.removeImportJobs", jobId, res => {
  357. new Toast(res.message);
  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. v-if="
  473. hasPermission(
  474. 'admin.view.youtubeVideos'
  475. )
  476. "
  477. class="button is-primary icon-with-button material-icons"
  478. @click="openAdvancedTable(slotProps.item)"
  479. :disabled="
  480. slotProps.item.removed ||
  481. slotProps.item.status !== 'success'
  482. "
  483. content="Manage imported videos"
  484. v-tippy
  485. >
  486. table_view
  487. </button>
  488. <button
  489. v-if="hasPermission('songs.update')"
  490. class="button is-primary icon-with-button material-icons"
  491. @click="
  492. editSongs(
  493. slotProps.item.response
  494. .successfulVideoIds
  495. )
  496. "
  497. :disabled="
  498. slotProps.item.removed ||
  499. slotProps.item.status !== 'success'
  500. "
  501. content="Create/edit song from videos"
  502. v-tippy
  503. >
  504. music_note
  505. </button>
  506. <button
  507. v-if="
  508. hasPermission('songs.update') &&
  509. hasPermission('apis.searchDiscogs')
  510. "
  511. class="button icon-with-button material-icons import-album-icon"
  512. @click="
  513. importAlbum(
  514. slotProps.item.response
  515. .successfulVideoIds
  516. )
  517. "
  518. :disabled="
  519. slotProps.item.removed ||
  520. slotProps.item.status !== 'success'
  521. "
  522. content="Import album from videos"
  523. v-tippy
  524. >
  525. album
  526. </button>
  527. <button
  528. v-if="hasPermission('playlists.songs.add')"
  529. class="button is-primary icon-with-button material-icons"
  530. @click="
  531. bulkEditPlaylist(
  532. slotProps.item.response
  533. .successfulVideoIds
  534. )
  535. "
  536. :disabled="
  537. slotProps.item.removed ||
  538. slotProps.item.status !== 'success'
  539. "
  540. content="Add/remove media to/from playlist"
  541. v-tippy
  542. >
  543. playlist_add
  544. </button>
  545. <button
  546. v-if="
  547. hasPermission('media.removeImportJobs')
  548. "
  549. class="button is-danger icon-with-button material-icons"
  550. @click.prevent="
  551. openModal({
  552. modal: 'confirm',
  553. props: {
  554. message:
  555. 'Note: Removing an import will not remove any videos or songs.',
  556. onCompleted: () =>
  557. removeImportJob(
  558. slotProps.item._id
  559. )
  560. }
  561. })
  562. "
  563. :disabled="
  564. slotProps.item.removed ||
  565. slotProps.item.status === 'in-progress'
  566. "
  567. content="Remove Import"
  568. v-tippy
  569. >
  570. delete_forever
  571. </button>
  572. </div>
  573. </template>
  574. <template #column-type="slotProps">
  575. <span :title="slotProps.item.type">{{
  576. slotProps.item.type
  577. }}</span>
  578. </template>
  579. <template #column-requestedBy="slotProps">
  580. <user-link :user-id="slotProps.item.requestedBy" />
  581. </template>
  582. <template #column-requestedAt="slotProps">
  583. <span
  584. :title="
  585. new Date(
  586. slotProps.item.requestedAt
  587. ).toString()
  588. "
  589. >{{
  590. utils.getDateFormatted(
  591. slotProps.item.requestedAt
  592. )
  593. }}</span
  594. >
  595. </template>
  596. <template #column-successful="slotProps">
  597. <span :title="slotProps.item.response.successful">{{
  598. slotProps.item.response.successful
  599. }}</span>
  600. </template>
  601. <template #column-alreadyInDatabase="slotProps">
  602. <span
  603. :title="
  604. slotProps.item.response.alreadyInDatabase
  605. "
  606. >{{
  607. slotProps.item.response.alreadyInDatabase
  608. }}</span
  609. >
  610. </template>
  611. <template #column-failed="slotProps">
  612. <span :title="slotProps.item.response.failed">{{
  613. slotProps.item.response.failed
  614. }}</span>
  615. </template>
  616. <template #column-status="slotProps">
  617. <span :title="slotProps.item.status">{{
  618. slotProps.item.status
  619. }}</span>
  620. </template>
  621. <template #column-url="slotProps">
  622. <a
  623. :href="slotProps.item.query.url"
  624. target="_blank"
  625. >{{ slotProps.item.query.url }}</a
  626. >
  627. </template>
  628. <template #column-musicOnly="slotProps">
  629. <span :title="slotProps.item.query.musicOnly">{{
  630. slotProps.item.query.musicOnly
  631. }}</span>
  632. </template>
  633. <template #column-_id="slotProps">
  634. <span :title="slotProps.item._id">{{
  635. slotProps.item._id
  636. }}</span>
  637. </template>
  638. </advanced-table>
  639. </div>
  640. </div>
  641. </div>
  642. </div>
  643. </template>
  644. <style lang="less" scoped>
  645. .admin-tab.import-tab {
  646. .section-row {
  647. display: flex;
  648. flex-wrap: wrap;
  649. height: 100%;
  650. .card {
  651. max-height: 100%;
  652. overflow-y: auto;
  653. flex-grow: 1;
  654. .control.is-expanded {
  655. .button {
  656. width: 100%;
  657. }
  658. &:not(:last-of-type) {
  659. margin-bottom: 10px !important;
  660. }
  661. &:last-of-type {
  662. margin-bottom: 0 !important;
  663. }
  664. }
  665. .control.is-grouped > .button {
  666. &:not(:last-child) {
  667. border-radius: @border-radius 0 0 @border-radius;
  668. }
  669. &:last-child {
  670. border-radius: 0 @border-radius @border-radius 0;
  671. }
  672. }
  673. }
  674. .left-section {
  675. height: 100%;
  676. max-width: 400px;
  677. margin-right: 20px !important;
  678. .checkbox-control label.label {
  679. margin-left: 10px;
  680. }
  681. .import-started {
  682. font-size: 18px;
  683. font-weight: 600;
  684. margin-bottom: 10px;
  685. }
  686. }
  687. .right-section {
  688. max-width: calc(100% - 400px);
  689. .row-options .material-icons.import-album-icon {
  690. background-color: var(--purple);
  691. color: var(--white);
  692. border-color: var(--purple);
  693. font-size: 20px;
  694. }
  695. }
  696. @media screen and (max-width: 1200px) {
  697. .card {
  698. flex-basis: 100%;
  699. max-height: unset;
  700. &.left-section {
  701. max-width: unset;
  702. margin-right: 0 !important;
  703. margin-bottom: 10px !important;
  704. }
  705. &.right-section {
  706. max-width: unset;
  707. }
  708. }
  709. }
  710. }
  711. }
  712. </style>