1
0

Songs.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786
  1. <template>
  2. <div>
  3. <page-metadata title="Admin | Songs" />
  4. <div class="admin-tab">
  5. <div class="button-row">
  6. <button class="button is-primary" @click="create()">
  7. Create song
  8. </button>
  9. <button
  10. class="button is-primary"
  11. @click="openModal('importPlaylist')"
  12. >
  13. Import playlist
  14. </button>
  15. <button
  16. class="button is-primary"
  17. @click="openModal('importAlbum')"
  18. >
  19. Import album
  20. </button>
  21. <run-job-dropdown :jobs="jobs" />
  22. </div>
  23. <advanced-table
  24. :column-default="columnDefault"
  25. :columns="columns"
  26. :filters="filters"
  27. data-action="songs.getData"
  28. name="admin-songs"
  29. :events="events"
  30. >
  31. <template #column-options="slotProps">
  32. <div class="row-options">
  33. <button
  34. class="button is-primary icon-with-button material-icons"
  35. @click="editOne(slotProps.item)"
  36. :disabled="slotProps.item.removed"
  37. content="Edit Song"
  38. v-tippy
  39. >
  40. edit
  41. </button>
  42. <quick-confirm
  43. v-if="slotProps.item.verified"
  44. @confirm="unverifyOne(slotProps.item._id)"
  45. >
  46. <button
  47. class="button is-danger icon-with-button material-icons"
  48. :disabled="slotProps.item.removed"
  49. content="Unverify Song"
  50. v-tippy
  51. >
  52. cancel
  53. </button>
  54. </quick-confirm>
  55. <button
  56. v-else
  57. class="button is-success icon-with-button material-icons"
  58. @click="verifyOne(slotProps.item._id)"
  59. :disabled="slotProps.item.removed"
  60. content="Verify Song"
  61. v-tippy
  62. >
  63. check_circle
  64. </button>
  65. <button
  66. class="button is-danger icon-with-button material-icons"
  67. @click.prevent="
  68. confirmAction({
  69. message:
  70. 'Removing this song will remove it from all playlists and cause a ratings recalculation.',
  71. action: 'deleteOne',
  72. params: slotProps.item._id
  73. })
  74. "
  75. :disabled="slotProps.item.removed"
  76. content="Delete Song"
  77. v-tippy
  78. >
  79. delete_forever
  80. </button>
  81. </div>
  82. </template>
  83. <template #column-thumbnailImage="slotProps">
  84. <img
  85. class="song-thumbnail"
  86. :src="slotProps.item.thumbnail"
  87. onerror="this.src='/assets/notes-transparent.png'"
  88. loading="lazy"
  89. />
  90. </template>
  91. <template #column-thumbnailUrl="slotProps">
  92. <a :href="slotProps.item.thumbnail" target="_blank">
  93. {{ slotProps.item.thumbnail }}
  94. </a>
  95. </template>
  96. <template #column-title="slotProps">
  97. <span :title="slotProps.item.title">{{
  98. slotProps.item.title
  99. }}</span>
  100. </template>
  101. <template #column-artists="slotProps">
  102. <span :title="slotProps.item.artists.join(', ')">{{
  103. slotProps.item.artists.join(", ")
  104. }}</span>
  105. </template>
  106. <template #column-genres="slotProps">
  107. <span :title="slotProps.item.genres.join(', ')">{{
  108. slotProps.item.genres.join(", ")
  109. }}</span>
  110. </template>
  111. <template #column-tags="slotProps">
  112. <span :title="slotProps.item.tags.join(', ')">{{
  113. slotProps.item.tags.join(", ")
  114. }}</span>
  115. </template>
  116. <template #column-likes="slotProps">
  117. <span :title="slotProps.item.likes">{{
  118. slotProps.item.likes
  119. }}</span>
  120. </template>
  121. <template #column-dislikes="slotProps">
  122. <span :title="slotProps.item.dislikes">{{
  123. slotProps.item.dislikes
  124. }}</span>
  125. </template>
  126. <template #column-_id="slotProps">
  127. <span :title="slotProps.item._id">{{
  128. slotProps.item._id
  129. }}</span>
  130. </template>
  131. <template #column-youtubeId="slotProps">
  132. <a
  133. :href="
  134. 'https://www.youtube.com/watch?v=' +
  135. `${slotProps.item.youtubeId}`
  136. "
  137. target="_blank"
  138. >
  139. {{ slotProps.item.youtubeId }}
  140. </a>
  141. </template>
  142. <template #column-verified="slotProps">
  143. <span :title="slotProps.item.verified">{{
  144. slotProps.item.verified
  145. }}</span>
  146. </template>
  147. <template #column-duration="slotProps">
  148. <span :title="slotProps.item.duration">{{
  149. slotProps.item.duration
  150. }}</span>
  151. </template>
  152. <template #column-skipDuration="slotProps">
  153. <span :title="slotProps.item.skipDuration">{{
  154. slotProps.item.skipDuration
  155. }}</span>
  156. </template>
  157. <template #column-requestedBy="slotProps">
  158. <user-id-to-username
  159. :user-id="slotProps.item.requestedBy"
  160. :link="true"
  161. />
  162. </template>
  163. <template #column-requestedAt="slotProps">
  164. <span :title="new Date(slotProps.item.requestedAt)">{{
  165. getDateFormatted(slotProps.item.requestedAt)
  166. }}</span>
  167. </template>
  168. <template #column-verifiedBy="slotProps">
  169. <user-id-to-username
  170. :user-id="slotProps.item.verifiedBy"
  171. :link="true"
  172. />
  173. </template>
  174. <template #column-verifiedAt="slotProps">
  175. <span :title="new Date(slotProps.item.verifiedAt)">{{
  176. getDateFormatted(slotProps.item.verifiedAt)
  177. }}</span>
  178. </template>
  179. <template #bulk-actions="slotProps">
  180. <div class="bulk-actions">
  181. <i
  182. class="material-icons edit-songs-icon"
  183. @click.prevent="editMany(slotProps.item)"
  184. content="Edit Songs"
  185. v-tippy
  186. tabindex="0"
  187. >
  188. edit
  189. </i>
  190. <i
  191. class="material-icons verify-songs-icon"
  192. @click.prevent="verifyMany(slotProps.item)"
  193. content="Verify Songs"
  194. v-tippy
  195. tabindex="0"
  196. >
  197. check_circle
  198. </i>
  199. <quick-confirm
  200. placement="left"
  201. @confirm="unverifyMany(slotProps.item)"
  202. tabindex="0"
  203. >
  204. <i
  205. class="material-icons unverify-songs-icon"
  206. content="Unverify Songs"
  207. v-tippy
  208. >
  209. cancel
  210. </i>
  211. </quick-confirm>
  212. <i
  213. class="material-icons tag-songs-icon"
  214. @click.prevent="setTags(slotProps.item)"
  215. content="Set Tags"
  216. v-tippy
  217. tabindex="0"
  218. >
  219. local_offer
  220. </i>
  221. <i
  222. class="material-icons artists-songs-icon"
  223. @click.prevent="setArtists(slotProps.item)"
  224. content="Set Artists"
  225. v-tippy
  226. tabindex="0"
  227. >
  228. group
  229. </i>
  230. <i
  231. class="material-icons genres-songs-icon"
  232. @click.prevent="setGenres(slotProps.item)"
  233. content="Set Genres"
  234. v-tippy
  235. tabindex="0"
  236. >
  237. theater_comedy
  238. </i>
  239. <i
  240. class="material-icons delete-icon"
  241. @click.prevent="
  242. confirmAction({
  243. message:
  244. 'Removing these songs will remove them from all playlists and cause a ratings recalculation.',
  245. action: 'deleteMany',
  246. params: slotProps.item
  247. })
  248. "
  249. content="Delete Songs"
  250. v-tippy
  251. tabindex="0"
  252. >
  253. delete_forever
  254. </i>
  255. </div>
  256. </template>
  257. </advanced-table>
  258. </div>
  259. </div>
  260. </template>
  261. <script>
  262. import { mapState, mapActions, mapGetters } from "vuex";
  263. import Toast from "toasters";
  264. import AdvancedTable from "@/components/AdvancedTable.vue";
  265. import RunJobDropdown from "@/components/RunJobDropdown.vue";
  266. export default {
  267. components: {
  268. AdvancedTable,
  269. RunJobDropdown
  270. },
  271. data() {
  272. return {
  273. columnDefault: {
  274. sortable: true,
  275. hidable: true,
  276. defaultVisibility: "shown",
  277. draggable: true,
  278. resizable: true,
  279. minWidth: 200,
  280. maxWidth: 600
  281. },
  282. columns: [
  283. {
  284. name: "options",
  285. displayName: "Options",
  286. properties: ["_id", "verified"],
  287. sortable: false,
  288. hidable: false,
  289. resizable: false,
  290. minWidth: 129,
  291. defaultWidth: 129
  292. },
  293. {
  294. name: "thumbnailImage",
  295. displayName: "Thumb",
  296. properties: ["thumbnail"],
  297. sortable: false,
  298. minWidth: 75,
  299. defaultWidth: 75,
  300. maxWidth: 75,
  301. resizable: false
  302. },
  303. {
  304. name: "title",
  305. displayName: "Title",
  306. properties: ["title"],
  307. sortProperty: "title"
  308. },
  309. {
  310. name: "artists",
  311. displayName: "Artists",
  312. properties: ["artists"],
  313. sortable: false
  314. },
  315. {
  316. name: "genres",
  317. displayName: "Genres",
  318. properties: ["genres"],
  319. sortable: false
  320. },
  321. {
  322. name: "tags",
  323. displayName: "Tags",
  324. properties: ["tags"],
  325. sortable: false
  326. },
  327. {
  328. name: "likes",
  329. displayName: "Likes",
  330. properties: ["likes"],
  331. sortProperty: "likes",
  332. minWidth: 100,
  333. defaultWidth: 100,
  334. defaultVisibility: "hidden"
  335. },
  336. {
  337. name: "dislikes",
  338. displayName: "Dislikes",
  339. properties: ["dislikes"],
  340. sortProperty: "dislikes",
  341. minWidth: 100,
  342. defaultWidth: 100,
  343. defaultVisibility: "hidden"
  344. },
  345. {
  346. name: "_id",
  347. displayName: "Song ID",
  348. properties: ["_id"],
  349. sortProperty: "_id",
  350. minWidth: 215,
  351. defaultWidth: 215
  352. },
  353. {
  354. name: "youtubeId",
  355. displayName: "YouTube ID",
  356. properties: ["youtubeId"],
  357. sortProperty: "youtubeId",
  358. minWidth: 120,
  359. defaultWidth: 120
  360. },
  361. {
  362. name: "verified",
  363. displayName: "Verified",
  364. properties: ["verified"],
  365. sortProperty: "verified",
  366. minWidth: 120,
  367. defaultWidth: 120
  368. },
  369. {
  370. name: "thumbnailUrl",
  371. displayName: "Thumbnail (URL)",
  372. properties: ["thumbnail"],
  373. sortProperty: "thumbnail",
  374. defaultVisibility: "hidden"
  375. },
  376. {
  377. name: "duration",
  378. displayName: "Duration",
  379. properties: ["duration"],
  380. sortProperty: "duration",
  381. defaultWidth: 200,
  382. defaultVisibility: "hidden"
  383. },
  384. {
  385. name: "skipDuration",
  386. displayName: "Skip Duration",
  387. properties: ["skipDuration"],
  388. sortProperty: "skipDuration",
  389. defaultWidth: 200,
  390. defaultVisibility: "hidden"
  391. },
  392. {
  393. name: "requestedBy",
  394. displayName: "Requested By",
  395. properties: ["requestedBy"],
  396. sortProperty: "requestedBy",
  397. defaultWidth: 200,
  398. defaultVisibility: "hidden"
  399. },
  400. {
  401. name: "requestedAt",
  402. displayName: "Requested At",
  403. properties: ["requestedAt"],
  404. sortProperty: "requestedAt",
  405. defaultWidth: 200,
  406. defaultVisibility: "hidden"
  407. },
  408. {
  409. name: "verifiedBy",
  410. displayName: "Verified By",
  411. properties: ["verifiedBy"],
  412. sortProperty: "verifiedBy",
  413. defaultWidth: 200,
  414. defaultVisibility: "hidden"
  415. },
  416. {
  417. name: "verifiedAt",
  418. displayName: "Verified At",
  419. properties: ["verifiedAt"],
  420. sortProperty: "verifiedAt",
  421. defaultWidth: 200,
  422. defaultVisibility: "hidden"
  423. }
  424. ],
  425. filters: [
  426. {
  427. name: "_id",
  428. displayName: "Song ID",
  429. property: "_id",
  430. filterTypes: ["exact"],
  431. defaultFilterType: "exact"
  432. },
  433. {
  434. name: "youtubeId",
  435. displayName: "YouTube ID",
  436. property: "youtubeId",
  437. filterTypes: ["contains", "exact", "regex"],
  438. defaultFilterType: "contains"
  439. },
  440. {
  441. name: "title",
  442. displayName: "Title",
  443. property: "title",
  444. filterTypes: ["contains", "exact", "regex"],
  445. defaultFilterType: "contains"
  446. },
  447. {
  448. name: "artists",
  449. displayName: "Artists",
  450. property: "artists",
  451. filterTypes: ["contains", "exact", "regex"],
  452. defaultFilterType: "contains",
  453. autosuggest: true,
  454. autosuggestDataAction: "songs.getArtists"
  455. },
  456. {
  457. name: "genres",
  458. displayName: "Genres",
  459. property: "genres",
  460. filterTypes: ["contains", "exact", "regex"],
  461. defaultFilterType: "contains",
  462. autosuggest: true,
  463. autosuggestDataAction: "songs.getGenres"
  464. },
  465. {
  466. name: "tags",
  467. displayName: "Tags",
  468. property: "tags",
  469. filterTypes: ["contains", "exact", "regex"],
  470. defaultFilterType: "contains",
  471. autosuggest: true,
  472. autosuggestDataAction: "songs.getTags"
  473. },
  474. {
  475. name: "thumbnail",
  476. displayName: "Thumbnail",
  477. property: "thumbnail",
  478. filterTypes: ["contains", "exact", "regex"],
  479. defaultFilterType: "contains"
  480. },
  481. {
  482. name: "requestedBy",
  483. displayName: "Requested By",
  484. property: "requestedBy",
  485. filterTypes: ["contains", "exact", "regex"],
  486. defaultFilterType: "contains"
  487. },
  488. {
  489. name: "requestedAt",
  490. displayName: "Requested At",
  491. property: "requestedAt",
  492. filterTypes: ["datetimeBefore", "datetimeAfter"],
  493. defaultFilterType: "datetimeBefore"
  494. },
  495. {
  496. name: "verifiedBy",
  497. displayName: "Verified By",
  498. property: "verifiedBy",
  499. filterTypes: ["contains", "exact", "regex"],
  500. defaultFilterType: "contains"
  501. },
  502. {
  503. name: "verifiedAt",
  504. displayName: "Verified At",
  505. property: "verifiedAt",
  506. filterTypes: ["datetimeBefore", "datetimeAfter"],
  507. defaultFilterType: "datetimeBefore"
  508. },
  509. {
  510. name: "verified",
  511. displayName: "Verified",
  512. property: "verified",
  513. filterTypes: ["boolean"],
  514. defaultFilterType: "boolean"
  515. },
  516. {
  517. name: "likes",
  518. displayName: "Likes",
  519. property: "likes",
  520. filterTypes: [
  521. "numberLesserEqual",
  522. "numberLesser",
  523. "numberGreater",
  524. "numberGreaterEqual",
  525. "numberEquals"
  526. ],
  527. defaultFilterType: "numberLesser"
  528. },
  529. {
  530. name: "dislikes",
  531. displayName: "Dislikes",
  532. property: "dislikes",
  533. filterTypes: [
  534. "numberLesserEqual",
  535. "numberLesser",
  536. "numberGreater",
  537. "numberGreaterEqual",
  538. "numberEquals"
  539. ],
  540. defaultFilterType: "numberLesser"
  541. },
  542. {
  543. name: "duration",
  544. displayName: "Duration",
  545. property: "duration",
  546. filterTypes: [
  547. "numberLesserEqual",
  548. "numberLesser",
  549. "numberGreater",
  550. "numberGreaterEqual",
  551. "numberEquals"
  552. ],
  553. defaultFilterType: "numberLesser"
  554. },
  555. {
  556. name: "skipDuration",
  557. displayName: "Skip Duration",
  558. property: "skipDuration",
  559. filterTypes: [
  560. "numberLesserEqual",
  561. "numberLesser",
  562. "numberGreater",
  563. "numberGreaterEqual",
  564. "numberEquals"
  565. ],
  566. defaultFilterType: "numberLesser"
  567. }
  568. ],
  569. events: {
  570. adminRoom: "songs",
  571. updated: {
  572. event: "admin.song.updated",
  573. id: "song._id",
  574. item: "song"
  575. },
  576. removed: {
  577. event: "admin.song.removed",
  578. id: "songId"
  579. }
  580. },
  581. jobs: [
  582. {
  583. name: "Update all songs",
  584. socket: "songs.updateAll"
  585. },
  586. {
  587. name: "Recalculate all song ratings",
  588. socket: "songs.recalculateAllRatings"
  589. }
  590. ]
  591. };
  592. },
  593. computed: {
  594. ...mapState("modals/editSong", {
  595. song: state => state.song
  596. }),
  597. ...mapGetters({
  598. socket: "websockets/getSocket"
  599. })
  600. },
  601. mounted() {
  602. if (this.$route.query.songId) {
  603. this.socket.dispatch(
  604. "songs.getSongFromSongId",
  605. this.$route.query.songId,
  606. res => {
  607. if (res.status === "success")
  608. this.editMany([res.data.song]);
  609. else new Toast("Song with that ID not found");
  610. }
  611. );
  612. }
  613. },
  614. methods: {
  615. create() {
  616. this.openModal({
  617. modal: "editSong",
  618. data: { song: { newSong: true } }
  619. });
  620. },
  621. editOne(song) {
  622. this.openModal({
  623. modal: "editSong",
  624. data: { song: { songId: song._id } }
  625. });
  626. },
  627. editMany(selectedRows) {
  628. if (selectedRows.length === 1) this.editOne(selectedRows[0]);
  629. else {
  630. const songs = selectedRows.map(row => ({
  631. songId: row._id
  632. }));
  633. this.openModal({ modal: "editSongs", data: { songs } });
  634. }
  635. },
  636. verifyOne(songId) {
  637. this.socket.dispatch("songs.verify", songId, res => {
  638. new Toast(res.message);
  639. });
  640. },
  641. verifyMany(selectedRows) {
  642. this.socket.dispatch(
  643. "songs.verifyMany",
  644. selectedRows.map(row => row._id),
  645. res => {
  646. new Toast(res.message);
  647. }
  648. );
  649. },
  650. unverifyOne(songId) {
  651. this.socket.dispatch("songs.unverify", songId, res => {
  652. new Toast(res.message);
  653. });
  654. },
  655. unverifyMany(selectedRows) {
  656. this.socket.dispatch(
  657. "songs.unverifyMany",
  658. selectedRows.map(row => row._id),
  659. res => {
  660. new Toast(res.message);
  661. }
  662. );
  663. },
  664. setTags(selectedRows) {
  665. this.openModal({
  666. modal: "bulkActions",
  667. data: {
  668. type: {
  669. name: "tags",
  670. action: "songs.editTags",
  671. items: selectedRows.map(row => row._id),
  672. regex: /^[a-zA-Z0-9_]{1,64}$|^[a-zA-Z0-9_]{1,64}\[[a-zA-Z0-9_]{1,64}\]$/,
  673. autosuggest: true,
  674. autosuggestDataAction: "songs.getTags"
  675. }
  676. }
  677. });
  678. },
  679. setArtists(selectedRows) {
  680. this.openModal({
  681. modal: "bulkActions",
  682. data: {
  683. type: {
  684. name: "artists",
  685. action: "songs.editArtists",
  686. items: selectedRows.map(row => row._id),
  687. regex: /^(?=.{1,64}$).*$/,
  688. autosuggest: true,
  689. autosuggestDataAction: "songs.getArtists"
  690. }
  691. }
  692. });
  693. },
  694. setGenres(selectedRows) {
  695. this.openModal({
  696. modal: "bulkActions",
  697. data: {
  698. type: {
  699. name: "genres",
  700. action: "songs.editGenres",
  701. items: selectedRows.map(row => row._id),
  702. regex: /^[\x00-\x7F]{1,32}$/,
  703. autosuggest: true,
  704. autosuggestDataAction: "songs.getGenres"
  705. }
  706. }
  707. });
  708. },
  709. deleteOne(songId) {
  710. this.socket.dispatch("songs.remove", songId, res => {
  711. new Toast(res.message);
  712. });
  713. },
  714. deleteMany(selectedRows) {
  715. this.socket.dispatch(
  716. "songs.removeMany",
  717. selectedRows.map(row => row._id),
  718. res => {
  719. new Toast(res.message);
  720. }
  721. );
  722. },
  723. getDateFormatted(createdAt) {
  724. const date = new Date(createdAt);
  725. const year = date.getFullYear();
  726. const month = `${date.getMonth() + 1}`.padStart(2, 0);
  727. const day = `${date.getDate()}`.padStart(2, 0);
  728. const hour = `${date.getHours()}`.padStart(2, 0);
  729. const minute = `${date.getMinutes()}`.padStart(2, 0);
  730. return `${year}-${month}-${day} ${hour}:${minute}`;
  731. },
  732. confirmAction({ message, action, params }) {
  733. this.openModal({
  734. modal: "confirm",
  735. data: {
  736. message,
  737. action,
  738. params,
  739. onCompleted: this.handleConfirmed
  740. }
  741. });
  742. },
  743. handleConfirmed({ action, params }) {
  744. if (typeof this[action] === "function") {
  745. if (params) this[action](params);
  746. else this[action]();
  747. }
  748. },
  749. ...mapActions("modalVisibility", ["openModal"])
  750. }
  751. };
  752. </script>
  753. <style lang="less" scoped>
  754. .song-thumbnail {
  755. display: block;
  756. width: 50px;
  757. height: 50px;
  758. margin: 0 auto;
  759. object-fit: contain;
  760. }
  761. :deep(.bulk-popup .bulk-actions) {
  762. .verify-songs-icon {
  763. color: var(--green);
  764. }
  765. & > span {
  766. position: relative;
  767. top: 6px;
  768. margin-left: 5px;
  769. height: 25px;
  770. & > div {
  771. height: 25px;
  772. & > .unverify-songs-icon {
  773. color: var(--dark-red);
  774. top: unset;
  775. margin-left: unset;
  776. }
  777. }
  778. }
  779. }
  780. </style>