osu-play 1.3.3 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -39,11 +39,11 @@ __export(exports_mod, {
39
39
  getDataDir: () => getDataDir,
40
40
  getConfigDir: () => getConfigDir
41
41
  });
42
- import path from "node:path";
42
+ import path2 from "node:path";
43
43
  import { existsSync } from "node:fs";
44
44
  import { spawn } from "node:child_process";
45
45
  function revealFile(filePath) {
46
- const command = process.platform === "darwin" ? { args: ["-R", filePath], executable: "open" } : process.platform === "win32" ? { args: ["/select,", filePath], executable: "explorer.exe" } : { args: [path.dirname(filePath)], executable: "xdg-open" };
46
+ const command = process.platform === "darwin" ? { args: ["-R", filePath], executable: "open" } : process.platform === "win32" ? { args: ["/select,", filePath], executable: "explorer.exe" } : { args: [path2.dirname(filePath)], executable: "xdg-open" };
47
47
  return new Promise((resolve, reject) => {
48
48
  const child = spawn(command.executable, command.args, {
49
49
  detached: true,
@@ -66,13 +66,13 @@ function getDataDir() {
66
66
  return xdg;
67
67
  const home = process.env.HOME;
68
68
  if (home)
69
- return path.join(home, ".local", "share");
69
+ return path2.join(home, ".local", "share");
70
70
  break;
71
71
  }
72
72
  case "darwin": {
73
73
  const home = process.env.HOME;
74
74
  if (home)
75
- return path.join(home, "Library", "Application Support");
75
+ return path2.join(home, "Library", "Application Support");
76
76
  break;
77
77
  }
78
78
  case "win32":
@@ -90,13 +90,13 @@ function getConfigDir() {
90
90
  return xdg;
91
91
  const home = process.env.HOME;
92
92
  if (home)
93
- return path.join(home, ".config");
93
+ return path2.join(home, ".config");
94
94
  break;
95
95
  }
96
96
  case "darwin": {
97
97
  const home = process.env.HOME;
98
98
  if (home)
99
- return path.join(home, "Library", "Preferences");
99
+ return path2.join(home, "Library", "Preferences");
100
100
  break;
101
101
  }
102
102
  case "win32":
@@ -105,18 +105,18 @@ function getConfigDir() {
105
105
  return;
106
106
  }
107
107
  function getDefaultOsuDataDir() {
108
- return path.join(getDataDir() ?? ".", "osu");
108
+ return path2.join(getDataDir() ?? ".", "osu");
109
109
  }
110
110
  function getDefaultConfigPath(appName = "osu-play") {
111
- return path.join(getConfigDir() ?? ".", appName);
111
+ return path2.join(getConfigDir() ?? ".", appName);
112
112
  }
113
113
  function hashedFilePath(hash, osuDataDir = getDefaultOsuDataDir()) {
114
- return path.join(osuDataDir, "files", hash.slice(0, 1), hash.slice(0, 2), hash);
114
+ return path2.join(osuDataDir, "files", hash.slice(0, 1), hash.slice(0, 2), hash);
115
115
  }
116
116
  function getRealmDBPath(legacyAppConfigDirOrOptions = {}, maybeOptions = {}) {
117
117
  const options = typeof legacyAppConfigDirOrOptions === "string" ? maybeOptions : legacyAppConfigDirOrOptions;
118
118
  const osuDataDir = options.osuDataDir ?? getDefaultOsuDataDir();
119
- const osuDBPath = path.join(osuDataDir, "client.realm");
119
+ const osuDBPath = path2.join(osuDataDir, "client.realm");
120
120
  if (!existsSync(osuDBPath)) {
121
121
  console.log(`[getRealmDBPath]: ${osuDBPath} not found`);
122
122
  return null;
@@ -514,12 +514,615 @@ var init_mod3 = __esm(() => {
514
514
  });
515
515
 
516
516
  // src/core/cli/main.ts
517
- import path3 from "node:path";
517
+ import path4 from "node:path";
518
518
  import { writeFileSync } from "node:fs";
519
519
  import { ProcessTerminal, TUI } from "@earendil-works/pi-tui";
520
520
  import yargs from "yargs/yargs";
521
521
  import { hideBin } from "yargs/helpers";
522
522
 
523
+ // src/core/api/server.ts
524
+ import { createReadStream } from "node:fs";
525
+ import { stat } from "node:fs/promises";
526
+ import { createServer } from "node:http";
527
+ import path from "node:path";
528
+ var API_VERSION = "1.16.1";
529
+ var SERVER_VERSION = "1.4.0";
530
+ var DEFAULT_HOST = "127.0.0.1";
531
+ var DEFAULT_PORT = 4533;
532
+ function responseBase(status) {
533
+ return {
534
+ openSubsonic: true,
535
+ serverVersion: SERVER_VERSION,
536
+ status,
537
+ type: "osu-play",
538
+ version: API_VERSION
539
+ };
540
+ }
541
+ function sendJson(response, body, statusCode = 200) {
542
+ const encoded = JSON.stringify(body);
543
+ response.writeHead(statusCode, {
544
+ "Cache-Control": "no-store",
545
+ "Content-Length": Buffer.byteLength(encoded),
546
+ "Content-Type": "application/json; charset=utf-8"
547
+ });
548
+ response.end(encoded);
549
+ }
550
+ function sendSuccess(response, data = {}) {
551
+ sendJson(response, {
552
+ "subsonic-response": {
553
+ ...responseBase("ok"),
554
+ ...data
555
+ }
556
+ });
557
+ }
558
+ function sendFailure(response, error, statusCode = 200) {
559
+ sendJson(response, {
560
+ "subsonic-response": {
561
+ ...responseBase("failed"),
562
+ error
563
+ }
564
+ }, statusCode);
565
+ }
566
+ function requiredParameter(response, params, name) {
567
+ const value = params.get(name)?.trim();
568
+ if (!value) {
569
+ sendFailure(response, {
570
+ code: 10,
571
+ message: `Required parameter '${name}' is missing.`
572
+ });
573
+ return null;
574
+ }
575
+ return value;
576
+ }
577
+ function hasValidCredentials(params) {
578
+ const username = params.get("u")?.trim();
579
+ const token = params.get("t")?.trim();
580
+ const salt = params.get("s")?.trim();
581
+ return Boolean(username && token && salt && /^[a-f0-9]{32}$/i.test(token) && params.get("f") === "json" && params.get("c")?.trim() && params.get("v")?.trim());
582
+ }
583
+ function parseNonNegativeInteger(value, fallback, maximum) {
584
+ if (value === null) {
585
+ return fallback;
586
+ }
587
+ if (!/^\d+$/.test(value)) {
588
+ return null;
589
+ }
590
+ const parsed = Number(value);
591
+ if (!Number.isSafeInteger(parsed) || parsed < 0) {
592
+ return null;
593
+ }
594
+ return maximum === undefined ? parsed : Math.min(parsed, maximum);
595
+ }
596
+ function albumPayload(album) {
597
+ return {
598
+ artist: album.artist,
599
+ ...album.coverArt ? { coverArt: album.coverArt } : {},
600
+ id: album.id,
601
+ name: album.name
602
+ };
603
+ }
604
+ function songPayload(song) {
605
+ return {
606
+ album: song.album,
607
+ albumId: song.albumId,
608
+ artist: song.artist,
609
+ ...song.coverArt ? { coverArt: song.coverArt } : {},
610
+ ...song.duration ? { duration: song.duration } : {},
611
+ id: song.id,
612
+ title: song.title,
613
+ track: song.track
614
+ };
615
+ }
616
+ function artistIndex(catalog) {
617
+ const groups = new Map;
618
+ for (const artist of catalog.artists) {
619
+ const firstCharacter = artist.name.trim().charAt(0).toUpperCase();
620
+ const name = /^[A-Z]$/.test(firstCharacter) ? firstCharacter : "#";
621
+ const group = groups.get(name) ?? [];
622
+ group.push({
623
+ ...artist.coverArt ? { coverArt: artist.coverArt } : {},
624
+ id: artist.id,
625
+ name: artist.name
626
+ });
627
+ groups.set(name, group);
628
+ }
629
+ return [...groups.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([name, artist]) => ({ artist, name }));
630
+ }
631
+ function contentType(fileName) {
632
+ switch (path.extname(fileName).toLowerCase()) {
633
+ case ".aac":
634
+ return "audio/aac";
635
+ case ".flac":
636
+ return "audio/flac";
637
+ case ".jpeg":
638
+ case ".jpg":
639
+ return "image/jpeg";
640
+ case ".m4a":
641
+ case ".mp4":
642
+ return "audio/mp4";
643
+ case ".mp3":
644
+ return "audio/mpeg";
645
+ case ".ogg":
646
+ case ".oga":
647
+ case ".opus":
648
+ return "audio/ogg";
649
+ case ".png":
650
+ return "image/png";
651
+ case ".wav":
652
+ return "audio/wav";
653
+ case ".webm":
654
+ return "audio/webm";
655
+ case ".webp":
656
+ return "image/webp";
657
+ default:
658
+ return "application/octet-stream";
659
+ }
660
+ }
661
+ function parseRange(rangeHeader, size) {
662
+ const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
663
+ if (!match || !match[1] && !match[2]) {
664
+ return null;
665
+ }
666
+ if (!match[1]) {
667
+ const suffixLength = Number(match[2]);
668
+ if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) {
669
+ return null;
670
+ }
671
+ return {
672
+ end: size - 1,
673
+ start: Math.max(0, size - suffixLength)
674
+ };
675
+ }
676
+ const start = Number(match[1]);
677
+ const requestedEnd = match[2] ? Number(match[2]) : size - 1;
678
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(requestedEnd) || start < 0 || requestedEnd < start || start >= size) {
679
+ return null;
680
+ }
681
+ return {
682
+ end: Math.min(requestedEnd, size - 1),
683
+ start
684
+ };
685
+ }
686
+ async function serveMedia(request, response, media, etag) {
687
+ let file;
688
+ try {
689
+ file = await stat(media.path);
690
+ } catch {
691
+ sendFailure(response, {
692
+ code: 70,
693
+ message: "Requested media is no longer available."
694
+ }, 404);
695
+ return;
696
+ }
697
+ if (!file.isFile()) {
698
+ sendFailure(response, {
699
+ code: 70,
700
+ message: "Requested media is not a file."
701
+ }, 404);
702
+ return;
703
+ }
704
+ const commonHeaders = {
705
+ "Accept-Ranges": "bytes",
706
+ "Cache-Control": "public, max-age=31536000, immutable",
707
+ "Content-Type": contentType(media.fileName),
708
+ ETag: `"${etag}"`
709
+ };
710
+ const rangeHeader = request.headers.range;
711
+ const range = rangeHeader ? parseRange(rangeHeader, file.size) : undefined;
712
+ if (rangeHeader && !range) {
713
+ response.writeHead(416, {
714
+ ...commonHeaders,
715
+ "Content-Range": `bytes */${file.size}`
716
+ });
717
+ response.end();
718
+ return;
719
+ }
720
+ const start = range?.start ?? 0;
721
+ const end = range?.end ?? file.size - 1;
722
+ const contentLength = Math.max(0, end - start + 1);
723
+ response.writeHead(range ? 206 : 200, {
724
+ ...commonHeaders,
725
+ "Content-Length": contentLength,
726
+ ...range ? { "Content-Range": `bytes ${start}-${end}/${file.size}` } : {}
727
+ });
728
+ if (request.method === "HEAD" || file.size === 0) {
729
+ response.end();
730
+ return;
731
+ }
732
+ const stream = createReadStream(media.path, { end, start });
733
+ const destroyStream = () => stream.destroy();
734
+ response.once("close", destroyStream);
735
+ stream.once("error", (error) => {
736
+ response.destroy(error);
737
+ });
738
+ stream.once("close", () => {
739
+ response.off("close", destroyStream);
740
+ });
741
+ stream.pipe(response);
742
+ }
743
+ async function routeRequest(request, response, catalogStore) {
744
+ const requestUrl = new URL(request.url ?? "/", `http://${DEFAULT_HOST}`);
745
+ const match = /^\/rest\/([A-Za-z0-9]+)\.view$/.exec(requestUrl.pathname);
746
+ if (!match) {
747
+ sendFailure(response, {
748
+ code: 0,
749
+ message: "Unsupported API endpoint."
750
+ }, 404);
751
+ return;
752
+ }
753
+ const endpoint = match[1];
754
+ const isMedia = endpoint === "stream" || endpoint === "getCoverArt";
755
+ const allowedMethod = request.method === "GET" || isMedia && request.method === "HEAD";
756
+ if (!allowedMethod) {
757
+ response.setHeader("Allow", isMedia ? "GET, HEAD" : "GET");
758
+ sendFailure(response, {
759
+ code: 0,
760
+ message: "Unsupported HTTP method."
761
+ }, 405);
762
+ return;
763
+ }
764
+ if (!hasValidCredentials(requestUrl.searchParams)) {
765
+ sendFailure(response, {
766
+ code: 40,
767
+ message: "Wrong username or password."
768
+ });
769
+ return;
770
+ }
771
+ if (endpoint === "getArtists" || endpoint === "getAlbumList2") {
772
+ const offset = endpoint === "getAlbumList2" ? parseNonNegativeInteger(requestUrl.searchParams.get("offset"), 0) : 0;
773
+ if (offset === 0) {
774
+ await catalogStore.refreshIfChanged();
775
+ }
776
+ }
777
+ const catalog = catalogStore.getCatalog();
778
+ switch (endpoint) {
779
+ case "ping":
780
+ sendSuccess(response);
781
+ return;
782
+ case "getArtists":
783
+ sendSuccess(response, {
784
+ artists: {
785
+ index: artistIndex(catalog)
786
+ }
787
+ });
788
+ return;
789
+ case "getAlbumList2": {
790
+ if (requestUrl.searchParams.get("type") !== "alphabeticalByName") {
791
+ sendFailure(response, {
792
+ code: 10,
793
+ message: "Only type=alphabeticalByName is supported."
794
+ });
795
+ return;
796
+ }
797
+ const offset = parseNonNegativeInteger(requestUrl.searchParams.get("offset"), 0);
798
+ const size = parseNonNegativeInteger(requestUrl.searchParams.get("size"), 10, 500);
799
+ if (offset === null || size === null) {
800
+ sendFailure(response, {
801
+ code: 10,
802
+ message: "Album offset and size must be non-negative integers."
803
+ });
804
+ return;
805
+ }
806
+ sendSuccess(response, {
807
+ albumList2: {
808
+ album: catalog.albums.slice(offset, offset + size).map(albumPayload)
809
+ }
810
+ });
811
+ return;
812
+ }
813
+ case "getAlbum": {
814
+ const id = requiredParameter(response, requestUrl.searchParams, "id");
815
+ if (!id)
816
+ return;
817
+ const album = catalog.albumsById.get(id);
818
+ if (!album) {
819
+ sendFailure(response, {
820
+ code: 70,
821
+ message: "Album not found."
822
+ });
823
+ return;
824
+ }
825
+ sendSuccess(response, {
826
+ album: {
827
+ ...albumPayload(album),
828
+ song: album.songs.map(songPayload)
829
+ }
830
+ });
831
+ return;
832
+ }
833
+ case "stream": {
834
+ const id = requiredParameter(response, requestUrl.searchParams, "id");
835
+ if (!id)
836
+ return;
837
+ const song = catalog.songsById.get(id);
838
+ if (!song) {
839
+ sendFailure(response, {
840
+ code: 70,
841
+ message: "Song not found."
842
+ }, 404);
843
+ return;
844
+ }
845
+ await serveMedia(request, response, {
846
+ fileName: song.fileName,
847
+ path: song.path
848
+ }, song.id);
849
+ return;
850
+ }
851
+ case "getCoverArt": {
852
+ const id = requiredParameter(response, requestUrl.searchParams, "id");
853
+ if (!id)
854
+ return;
855
+ const cover = catalog.coversById.get(id);
856
+ if (!cover) {
857
+ sendFailure(response, {
858
+ code: 70,
859
+ message: "Cover art not found."
860
+ }, 404);
861
+ return;
862
+ }
863
+ await serveMedia(request, response, cover, id);
864
+ return;
865
+ }
866
+ case "getPlaylists":
867
+ sendSuccess(response, {
868
+ playlists: {
869
+ playlist: []
870
+ }
871
+ });
872
+ return;
873
+ case "getStarred2":
874
+ sendSuccess(response, {
875
+ starred2: {
876
+ song: []
877
+ }
878
+ });
879
+ return;
880
+ case "scrobble": {
881
+ const id = requiredParameter(response, requestUrl.searchParams, "id");
882
+ if (!id)
883
+ return;
884
+ if (!catalog.songsById.has(id)) {
885
+ sendFailure(response, {
886
+ code: 70,
887
+ message: "Song not found."
888
+ });
889
+ return;
890
+ }
891
+ sendSuccess(response);
892
+ return;
893
+ }
894
+ case "getLyricsBySongId":
895
+ sendSuccess(response, {
896
+ lyricsList: {
897
+ structuredLyrics: []
898
+ }
899
+ });
900
+ return;
901
+ case "getLyrics":
902
+ sendSuccess(response, {
903
+ lyrics: {
904
+ value: ""
905
+ }
906
+ });
907
+ return;
908
+ case "getPlaylist":
909
+ sendFailure(response, {
910
+ code: 70,
911
+ message: "Playlist not found; osu-play API mode is read-only."
912
+ });
913
+ return;
914
+ case "createPlaylist":
915
+ case "updatePlaylist":
916
+ case "star":
917
+ case "unstar":
918
+ sendFailure(response, {
919
+ code: 0,
920
+ message: "This operation is not supported by read-only osu-play API mode."
921
+ });
922
+ return;
923
+ default:
924
+ sendFailure(response, {
925
+ code: 0,
926
+ message: `Endpoint '${endpoint}.view' is not supported.`
927
+ });
928
+ }
929
+ }
930
+ async function startApiServer(options) {
931
+ const host = options.host ?? DEFAULT_HOST;
932
+ const requestedPort = options.port ?? DEFAULT_PORT;
933
+ const server = createServer((request, response) => {
934
+ routeRequest(request, response, options.catalogStore).catch((error) => {
935
+ if (!response.headersSent) {
936
+ sendFailure(response, {
937
+ code: 0,
938
+ message: "Internal server error."
939
+ }, 500);
940
+ } else {
941
+ response.destroy(error instanceof Error ? error : new Error(String(error)));
942
+ }
943
+ });
944
+ });
945
+ server.requestTimeout = 30000;
946
+ server.headersTimeout = 1e4;
947
+ await new Promise((resolve, reject) => {
948
+ const handleError = (error) => {
949
+ server.off("listening", handleListening);
950
+ reject(error);
951
+ };
952
+ const handleListening = () => {
953
+ server.off("error", handleError);
954
+ resolve();
955
+ };
956
+ server.once("error", handleError);
957
+ server.once("listening", handleListening);
958
+ server.listen(requestedPort, host);
959
+ });
960
+ const address = server.address();
961
+ if (!address || typeof address === "string") {
962
+ await new Promise((resolve) => server.close(() => resolve()));
963
+ throw new Error("Could not determine the API server address.");
964
+ }
965
+ const port = address.port;
966
+ return {
967
+ async close() {
968
+ if (!server.listening) {
969
+ return;
970
+ }
971
+ await new Promise((resolve, reject) => {
972
+ server.close((error) => {
973
+ if (error)
974
+ reject(error);
975
+ else
976
+ resolve();
977
+ });
978
+ server.closeIdleConnections();
979
+ });
980
+ },
981
+ host,
982
+ port,
983
+ url: `http://${host}:${port}`
984
+ };
985
+ }
986
+
987
+ // src/core/lazer/library.ts
988
+ import { stat as stat2 } from "node:fs/promises";
989
+
990
+ // src/core/api/catalog.ts
991
+ init_mod();
992
+ import { createHash } from "node:crypto";
993
+ function stableId(kind, value) {
994
+ return createHash("sha256").update(`${kind}\x00${value}`).digest("hex");
995
+ }
996
+ function normalizedParts(parts) {
997
+ return [...new Set(parts.map((part) => part?.trim()).filter(Boolean))];
998
+ }
999
+ function formatMetadataText(primary, unicode, fallback) {
1000
+ return normalizedParts([primary, unicode]).join(" / ") || fallback;
1001
+ }
1002
+ function getNamedFile(fileName, beatmapSet) {
1003
+ if (!fileName) {
1004
+ return null;
1005
+ }
1006
+ for (const file of beatmapSet.Files) {
1007
+ if (file.Filename === fileName && file.File?.Hash) {
1008
+ return {
1009
+ fileName,
1010
+ hash: file.File.Hash
1011
+ };
1012
+ }
1013
+ }
1014
+ return null;
1015
+ }
1016
+ function beatmapSetKey(beatmapSet) {
1017
+ return String(beatmapSet.ID ?? beatmapSet.Hash ?? "");
1018
+ }
1019
+ function compareText(left, right) {
1020
+ return left.localeCompare(right, undefined, {
1021
+ sensitivity: "base",
1022
+ usage: "sort"
1023
+ });
1024
+ }
1025
+ function buildMusicCatalog(beatmapSets, osuDataDir) {
1026
+ const albums = [];
1027
+ const coversById = new Map;
1028
+ const seenAudioHashes = new Set;
1029
+ const sortedSets = [...beatmapSets].filter((beatmapSet) => !beatmapSet.DeletePending).sort((left, right) => compareText(beatmapSetKey(left), beatmapSetKey(right)));
1030
+ for (const beatmapSet of sortedSets) {
1031
+ const pendingByHash = new Map;
1032
+ let albumCoverArt;
1033
+ for (const beatmap of beatmapSet.Beatmaps) {
1034
+ const metadata = beatmap.Metadata;
1035
+ const audio = getNamedFile(metadata.AudioFile, beatmapSet);
1036
+ if (!audio) {
1037
+ continue;
1038
+ }
1039
+ const background = getNamedFile(metadata.BackgroundFile, beatmapSet);
1040
+ if (background) {
1041
+ coversById.set(background.hash, {
1042
+ fileName: background.fileName,
1043
+ path: hashedFilePath(background.hash, osuDataDir)
1044
+ });
1045
+ albumCoverArt ??= background.hash;
1046
+ }
1047
+ const durationMs = Math.max(0, beatmap.Length ?? 0);
1048
+ const existing = pendingByHash.get(audio.hash);
1049
+ if (existing) {
1050
+ existing.durationMs = Math.max(existing.durationMs, durationMs);
1051
+ existing.coverArt ??= background?.hash;
1052
+ continue;
1053
+ }
1054
+ pendingByHash.set(audio.hash, {
1055
+ artist: formatMetadataText(metadata.Artist, metadata.ArtistUnicode, "Unknown Artist"),
1056
+ coverArt: background?.hash,
1057
+ durationMs,
1058
+ fileName: audio.fileName,
1059
+ hash: audio.hash,
1060
+ title: formatMetadataText(metadata.Title, metadata.TitleUnicode, "Unknown Title")
1061
+ });
1062
+ }
1063
+ const retained = [...pendingByHash.values()].filter((song) => !seenAudioHashes.has(song.hash));
1064
+ if (retained.length === 0) {
1065
+ continue;
1066
+ }
1067
+ for (const song of retained) {
1068
+ seenAudioHashes.add(song.hash);
1069
+ }
1070
+ const firstSong = retained[0];
1071
+ const setKey = beatmapSetKey(beatmapSet) || firstSong.hash;
1072
+ const albumId = stableId("album", setKey);
1073
+ const albumName = firstSong.title;
1074
+ const albumArtist = firstSong.artist;
1075
+ const coverArt = albumCoverArt ?? firstSong.coverArt;
1076
+ const songs = retained.map((song, index) => {
1077
+ const duration = Math.round(song.durationMs / 1000);
1078
+ return {
1079
+ album: albumName,
1080
+ albumId,
1081
+ artist: song.artist,
1082
+ ...coverArt ? { coverArt } : {},
1083
+ ...duration > 0 ? { duration } : {},
1084
+ fileName: song.fileName,
1085
+ id: song.hash,
1086
+ path: hashedFilePath(song.hash, osuDataDir),
1087
+ title: song.title,
1088
+ track: index + 1
1089
+ };
1090
+ });
1091
+ albums.push({
1092
+ artist: albumArtist,
1093
+ ...coverArt ? { coverArt } : {},
1094
+ id: albumId,
1095
+ name: albumName,
1096
+ songs
1097
+ });
1098
+ }
1099
+ albums.sort((left, right) => compareText(left.name, right.name) || left.id.localeCompare(right.id));
1100
+ const albumsById = new Map(albums.map((album) => [album.id, album]));
1101
+ const songsById = new Map(albums.flatMap((album) => album.songs.map((song) => [song.id, song])));
1102
+ const artistsByName = new Map;
1103
+ for (const album of albums) {
1104
+ const normalizedName = album.artist.trim().toLocaleLowerCase();
1105
+ const existing = artistsByName.get(normalizedName);
1106
+ if (existing) {
1107
+ existing.coverArt ??= album.coverArt;
1108
+ continue;
1109
+ }
1110
+ artistsByName.set(normalizedName, {
1111
+ ...album.coverArt ? { coverArt: album.coverArt } : {},
1112
+ id: stableId("artist", normalizedName),
1113
+ name: album.artist
1114
+ });
1115
+ }
1116
+ const artists = [...artistsByName.values()].sort((left, right) => compareText(left.name, right.name) || left.id.localeCompare(right.id));
1117
+ return {
1118
+ albums,
1119
+ albumsById,
1120
+ artists,
1121
+ coversById,
1122
+ songsById
1123
+ };
1124
+ }
1125
+
523
1126
  // src/core/playlist/mod.ts
524
1127
  init_mod();
525
1128
  function uniqueParts(parts) {
@@ -564,12 +1167,123 @@ function buildPlaylist(beatmapSets, osuDataDir) {
564
1167
  return playlist;
565
1168
  }
566
1169
 
1170
+ // src/core/lazer/library.ts
1171
+ init_mod();
1172
+ function formatRealmLoadError(error) {
1173
+ return [
1174
+ "Realm native bindings could not be loaded.",
1175
+ `Current Node runtime: ${process.version}.`,
1176
+ "To repair Realm bindings in this project, run `bun run repair:realm` (or `bun run setup` to reinstall dependencies).",
1177
+ "If you installed dependencies on Node 22, switch to Node 20 LTS and rerun the repair command.",
1178
+ `Original error: ${error instanceof Error ? error.message : String(error)}`
1179
+ ].join(`
1180
+ `);
1181
+ }
1182
+ async function loadRealmDependencies() {
1183
+ try {
1184
+ const [{ default: Realm8 }, lazerModule] = await Promise.all([
1185
+ import("realm"),
1186
+ Promise.resolve().then(() => (init_mod3(), exports_mod2))
1187
+ ]);
1188
+ return {
1189
+ Realm: Realm8,
1190
+ ...lazerModule
1191
+ };
1192
+ } catch (error) {
1193
+ throw new Error(formatRealmLoadError(error), { cause: error });
1194
+ }
1195
+ }
1196
+ async function projectLazerLibrary(osuDataDir, projector) {
1197
+ const realmDBPath = getRealmDBPath({ osuDataDir });
1198
+ if (!realmDBPath) {
1199
+ throw new Error("Realm DB not found");
1200
+ }
1201
+ const {
1202
+ Realm: Realm8,
1203
+ formatLazerSchemaCompatibilityError: formatLazerSchemaCompatibilityError2,
1204
+ getBeatmapSets: getBeatmapSets2,
1205
+ getLazerDB: getLazerDB2,
1206
+ inspectLazerSchema: inspectLazerSchema2
1207
+ } = await loadRealmDependencies();
1208
+ Realm8.flags.ALLOW_CLEAR_TEST_STATE = true;
1209
+ const realm = await getLazerDB2(realmDBPath);
1210
+ try {
1211
+ const schemaReport = inspectLazerSchema2(realm);
1212
+ if (!schemaReport.compatible) {
1213
+ throw new Error(formatLazerSchemaCompatibilityError2(schemaReport));
1214
+ }
1215
+ return projector(getBeatmapSets2(realm));
1216
+ } finally {
1217
+ realm.close();
1218
+ }
1219
+ }
1220
+ function loadPlaylistFromLazer(osuDataDir) {
1221
+ return projectLazerLibrary(osuDataDir, (beatmapSets) => buildPlaylist(beatmapSets, osuDataDir));
1222
+ }
1223
+ function loadMusicCatalogFromLazer(osuDataDir) {
1224
+ return projectLazerLibrary(osuDataDir, (beatmapSets) => buildMusicCatalog(beatmapSets, osuDataDir));
1225
+ }
1226
+ async function deleteBeatmapSetFromLazer(track, osuDataDir) {
1227
+ const { deleteBeatmapSet: deleteBeatmapSet2 } = await loadRealmDependencies();
1228
+ await deleteBeatmapSet2(track, osuDataDir);
1229
+ }
1230
+ async function createRefreshingCatalogStore(options) {
1231
+ let catalog = await options.loadCatalog();
1232
+ let signature = await options.getSignature();
1233
+ let refreshPromise = null;
1234
+ return {
1235
+ getCatalog() {
1236
+ return catalog;
1237
+ },
1238
+ async refreshIfChanged() {
1239
+ if (refreshPromise) {
1240
+ return refreshPromise;
1241
+ }
1242
+ refreshPromise = (async () => {
1243
+ try {
1244
+ const nextSignature = await options.getSignature();
1245
+ if (nextSignature === signature) {
1246
+ return catalog;
1247
+ }
1248
+ const nextCatalog = await options.loadCatalog();
1249
+ catalog = nextCatalog;
1250
+ signature = nextSignature;
1251
+ } catch (error) {
1252
+ options.onRefreshError?.(error);
1253
+ }
1254
+ return catalog;
1255
+ })();
1256
+ try {
1257
+ return await refreshPromise;
1258
+ } finally {
1259
+ refreshPromise = null;
1260
+ }
1261
+ }
1262
+ };
1263
+ }
1264
+ async function createLazerCatalogStore(osuDataDir, onRefreshError = (error) => {
1265
+ console.warn(`[WARN] Could not refresh the osu!lazer catalog; continuing with the last good snapshot: ${error instanceof Error ? error.message : String(error)}`);
1266
+ }) {
1267
+ const realmDBPath = getRealmDBPath({ osuDataDir });
1268
+ if (!realmDBPath) {
1269
+ throw new Error("Realm DB not found");
1270
+ }
1271
+ return createRefreshingCatalogStore({
1272
+ async getSignature() {
1273
+ const file = await stat2(realmDBPath);
1274
+ return `${file.mtimeMs}:${file.size}`;
1275
+ },
1276
+ loadCatalog: () => loadMusicCatalogFromLazer(osuDataDir),
1277
+ onRefreshError
1278
+ });
1279
+ }
1280
+
567
1281
  // src/core/player/mpv.ts
568
1282
  import { spawn as spawn2 } from "node:child_process";
569
1283
  import { existsSync as existsSync2, rmSync } from "node:fs";
570
1284
  import net from "node:net";
571
1285
  import os from "node:os";
572
- import path2 from "node:path";
1286
+ import path3 from "node:path";
573
1287
  import { setTimeout as delay } from "node:timers/promises";
574
1288
  function getErrorCode(error) {
575
1289
  if (error && typeof error === "object" && "code" in error) {
@@ -591,7 +1305,7 @@ function createSocketPath() {
591
1305
  if (process.platform === "win32") {
592
1306
  return `\\\\.\\pipe\\${id}`;
593
1307
  }
594
- return path2.join(os.tmpdir(), `${id}.sock`);
1308
+ return path3.join(os.tmpdir(), `${id}.sock`);
595
1309
  }
596
1310
  function isControlText(value) {
597
1311
  return [...value].some((character) => {
@@ -1015,10 +1729,13 @@ class PlaylistPlayerSession {
1015
1729
  loop;
1016
1730
  deleteTrack;
1017
1731
  playlist;
1732
+ random;
1018
1733
  reloadPlaylist;
1019
1734
  revealTrack;
1020
1735
  searchQuery = "";
1021
1736
  selectedIndex = 0;
1737
+ shuffle;
1738
+ shuffleOrder = [];
1022
1739
  operationQueue = Promise.resolve();
1023
1740
  unsubscribeBackend;
1024
1741
  constructor(playlist, backend, options = {}) {
@@ -1026,8 +1743,13 @@ class PlaylistPlayerSession {
1026
1743
  this.playlist = playlist;
1027
1744
  this.deleteTrack = options.deleteTrack;
1028
1745
  this.loop = options.loop ?? false;
1746
+ this.random = options.random ?? Math.random;
1029
1747
  this.reloadPlaylist = options.reloadPlaylist;
1030
1748
  this.revealTrack = options.revealTrack;
1749
+ this.shuffle = options.shuffle ?? false;
1750
+ if (this.shuffle) {
1751
+ this.resetShuffleOrder(this.selectedIndex);
1752
+ }
1031
1753
  this.unsubscribeBackend = backend.subscribe((event) => {
1032
1754
  this.handleBackendEvent(event);
1033
1755
  });
@@ -1045,6 +1767,7 @@ class PlaylistPlayerSession {
1045
1767
  playlist: this.playlist,
1046
1768
  searchQuery: this.searchQuery,
1047
1769
  selectedIndex: this.selectedIndex,
1770
+ shuffle: this.shuffle,
1048
1771
  status: backendSnapshot.status,
1049
1772
  timePositionSeconds: backendSnapshot.timePositionSeconds
1050
1773
  };
@@ -1119,30 +1842,43 @@ class PlaylistPlayerSession {
1119
1842
  this.loop = !this.loop;
1120
1843
  this.emit();
1121
1844
  }
1845
+ toggleShuffle() {
1846
+ this.shuffle = !this.shuffle;
1847
+ if (this.shuffle) {
1848
+ this.resetShuffleOrder(this.currentIndex ?? this.selectedIndex);
1849
+ }
1850
+ this.emit();
1851
+ }
1122
1852
  appendSearchQuery(text) {
1123
- if (!text) {
1853
+ if (!text)
1124
1854
  return;
1125
- }
1126
1855
  this.searchQuery += text;
1127
1856
  this.syncSelectionToSearch();
1128
1857
  }
1129
1858
  deleteSearchCharacter() {
1130
- if (!this.searchQuery) {
1859
+ if (!this.searchQuery)
1131
1860
  return;
1132
- }
1133
1861
  this.searchQuery = this.searchQuery.slice(0, -1);
1134
1862
  this.syncSelectionToSearch();
1135
1863
  }
1864
+ deleteSearchWord() {
1865
+ if (!this.searchQuery)
1866
+ return;
1867
+ this.searchQuery = this.searchQuery.replace(/\s*\S+\s*$/, "");
1868
+ this.syncSelectionToSearch();
1869
+ }
1136
1870
  clearSearch() {
1137
- if (!this.searchQuery) {
1871
+ if (!this.searchQuery)
1138
1872
  return;
1139
- }
1140
1873
  this.searchQuery = "";
1141
1874
  this.emit();
1142
1875
  }
1143
1876
  async playSelected() {
1144
1877
  const selectedIndex = this.selectedIndex;
1145
1878
  await this.enqueueOperation(async () => {
1879
+ if (this.shuffle) {
1880
+ this.resetShuffleOrder(selectedIndex);
1881
+ }
1146
1882
  await this.playIndex(selectedIndex);
1147
1883
  });
1148
1884
  }
@@ -1259,6 +1995,9 @@ class PlaylistPlayerSession {
1259
1995
  const nextCurrentIndex = this.playlist.findIndex((playlistTrack) => playlistTrack.hash === currentTrack.hash);
1260
1996
  this.currentIndex = nextCurrentIndex === -1 ? null : nextCurrentIndex;
1261
1997
  }
1998
+ if (this.shuffle) {
1999
+ this.resetShuffleOrder(this.currentIndex ?? this.selectedIndex);
2000
+ }
1262
2001
  this.emit();
1263
2002
  } catch (error) {
1264
2003
  this.reportError(error);
@@ -1304,6 +2043,19 @@ class PlaylistPlayerSession {
1304
2043
  return null;
1305
2044
  }
1306
2045
  const baseIndex = this.currentIndex ?? this.selectedIndex;
2046
+ if (this.shuffle) {
2047
+ let orderIndex = this.shuffleOrder.indexOf(baseIndex);
2048
+ if (orderIndex === -1) {
2049
+ this.resetShuffleOrder(baseIndex);
2050
+ orderIndex = 0;
2051
+ }
2052
+ const nextOrderIndex = orderIndex + delta;
2053
+ const shouldWrap2 = wrap || this.loop;
2054
+ if (nextOrderIndex < 0 || nextOrderIndex >= this.shuffleOrder.length) {
2055
+ return shouldWrap2 ? this.shuffleOrder[wrapIndex(nextOrderIndex, this.shuffleOrder.length)] ?? null : null;
2056
+ }
2057
+ return this.shuffleOrder[nextOrderIndex] ?? null;
2058
+ }
1307
2059
  const nextIndex = baseIndex + delta;
1308
2060
  const shouldWrap = wrap || this.loop;
1309
2061
  if (nextIndex < 0) {
@@ -1314,6 +2066,17 @@ class PlaylistPlayerSession {
1314
2066
  }
1315
2067
  return nextIndex;
1316
2068
  }
2069
+ resetShuffleOrder(firstIndex) {
2070
+ const remainingIndices = this.playlist.map((_, index) => index).filter((index) => index !== firstIndex);
2071
+ for (let index = remainingIndices.length - 1;index > 0; index -= 1) {
2072
+ const swapIndex = Math.floor(this.random() * (index + 1));
2073
+ [remainingIndices[index], remainingIndices[swapIndex]] = [
2074
+ remainingIndices[swapIndex],
2075
+ remainingIndices[index]
2076
+ ];
2077
+ }
2078
+ this.shuffleOrder = this.playlist[firstIndex] ? [firstIndex, ...remainingIndices] : remainingIndices;
2079
+ }
1317
2080
  handleBackendEvent(event) {
1318
2081
  switch (event.type) {
1319
2082
  case "state":
@@ -1533,6 +2296,10 @@ class PlaylistPlayerScreen {
1533
2296
  this.session.toggleLoop();
1534
2297
  return;
1535
2298
  }
2299
+ if (matchesKey(data, "x")) {
2300
+ this.session.toggleShuffle();
2301
+ return;
2302
+ }
1536
2303
  if (matchesKey(data, "o")) {
1537
2304
  this.session.revealSelectedTrack();
1538
2305
  return;
@@ -1570,7 +2337,7 @@ class PlaylistPlayerScreen {
1570
2337
  } else if (this.searchMode || this.snapshot.searchQuery) {
1571
2338
  lines.push(truncateToWidth(style(`/ ${this.snapshot.searchQuery}${this.searchMode ? "▏" : ""}`, CYAN), width));
1572
2339
  } else {
1573
- lines.push(truncateToWidth(style(`${this.snapshot.selectedIndex + 1}/${Math.max(playlist.length, 1)} · loop ${this.snapshot.loop ? "on" : "off"} · ${this.snapshot.backendName}`, DIM), width));
2340
+ lines.push(truncateToWidth(style(`${this.snapshot.selectedIndex + 1}/${Math.max(playlist.length, 1)} · loop ${this.snapshot.loop ? "on" : "off"} · shuffle ${this.snapshot.shuffle ? "on" : "off"} · ${this.snapshot.backendName}`, DIM), width));
1574
2341
  }
1575
2342
  if (playlist.length === 0) {
1576
2343
  lines.push(truncateToWidth("No tracks were found in your osu!lazer library.", width));
@@ -1596,7 +2363,7 @@ class PlaylistPlayerScreen {
1596
2363
  while (lines.length < viewportHeight - 1) {
1597
2364
  lines.push("");
1598
2365
  }
1599
- lines.push(truncateToWidth(style(this.searchMode ? "type to filter · ↑/↓ results · enter play · backspace edit · esc leave" : "j/k move · ⏎ play · space pause · n/p track · h/l seek · o reveal · r loop · d delete · / search · q quit", DIM), width));
2366
+ lines.push(truncateToWidth(style(this.searchMode ? "type to filter · ↑/↓ results · enter play · ctrl-w word · esc leave" : "j/k move · ⏎ play · space pause · n/p track · h/l seek · x shuffle · r loop · o reveal · d delete · / search · q quit", DIM), width));
1600
2367
  return lines;
1601
2368
  }
1602
2369
  armPendingGoToTop() {
@@ -1649,6 +2416,10 @@ class PlaylistPlayerScreen {
1649
2416
  this.session.deleteSearchCharacter();
1650
2417
  return;
1651
2418
  }
2419
+ if (matchesKey(data, Key.ctrl("w"))) {
2420
+ this.session.deleteSearchWord();
2421
+ return;
2422
+ }
1652
2423
  if (matchesKey(data, Key.ctrl("u"))) {
1653
2424
  this.session.clearSearch();
1654
2425
  return;
@@ -1691,33 +2462,17 @@ class PlaylistPlayerScreen {
1691
2462
 
1692
2463
  // src/core/cli/main.ts
1693
2464
  init_mod();
1694
- function formatRealmLoadError(error) {
1695
- return [
1696
- "Realm native bindings could not be loaded.",
1697
- `Current Node runtime: ${process.version}.`,
1698
- "To repair Realm bindings in this project, run `bun run repair:realm` (or `bun run setup` to reinstall dependencies).",
1699
- "If you installed dependencies on Node 22, switch to Node 20 LTS and rerun the repair command.",
1700
- `Original error: ${error instanceof Error ? error.message : String(error)}`
1701
- ].join(`
1702
- `);
1703
- }
1704
- async function loadRealmDependencies() {
1705
- try {
1706
- const [{ default: Realm8 }, lazerModule] = await Promise.all([
1707
- import("realm"),
1708
- Promise.resolve().then(() => (init_mod3(), exports_mod2))
1709
- ]);
1710
- return {
1711
- Realm: Realm8,
1712
- ...lazerModule
1713
- };
1714
- } catch (error) {
1715
- throw new Error(formatRealmLoadError(error), { cause: error });
1716
- }
1717
- }
1718
2465
  function getArgs() {
1719
- return yargs(hideBin(process.argv)).scriptName("osu-play").usage(`Play music from your osu!lazer beatmaps in a minimal terminal player
1720
- Usage: $0 [options]`).option("reload", {
2466
+ return yargs(hideBin(process.argv)).scriptName("osu-play").usage(`Play or serve music from your osu!lazer beatmaps
2467
+ Usage: $0 [options]`).option("api", {
2468
+ type: "boolean",
2469
+ default: false,
2470
+ describe: "Run a localhost Subsonic API for music clients such as Kopuz"
2471
+ }).option("apiPort", {
2472
+ type: "number",
2473
+ default: 4533,
2474
+ describe: "Port for --api mode (binds to 127.0.0.1)"
2475
+ }).option("reload", {
1721
2476
  type: "boolean",
1722
2477
  default: false,
1723
2478
  alias: "r",
@@ -1739,44 +2494,38 @@ Usage: $0 [options]`).option("reload", {
1739
2494
  default: false,
1740
2495
  alias: "l",
1741
2496
  describe: "Loop the playlist when playback reaches the end"
2497
+ }).option("shuffle", {
2498
+ type: "boolean",
2499
+ default: false,
2500
+ alias: "s",
2501
+ describe: "Play the playlist in shuffled order"
2502
+ }).check((argv) => {
2503
+ if (!Number.isInteger(argv.apiPort) || argv.apiPort < 1 || argv.apiPort > 65535) {
2504
+ throw new Error("--apiPort must be an integer between 1 and 65535");
2505
+ }
2506
+ const conflictingMode = [
2507
+ argv.exportPlaylist ? "exportPlaylist" : null,
2508
+ argv.loop ? "loop" : null,
2509
+ argv.shuffle ? "shuffle" : null
2510
+ ].find(Boolean);
2511
+ if (argv.api && conflictingMode) {
2512
+ throw new Error(`--api cannot be combined with --${conflictingMode}`);
2513
+ }
2514
+ return true;
1742
2515
  }).alias("help", "h").help().parse();
1743
2516
  }
1744
- async function createPlaylist(osuDataDir) {
1745
- const realmDBPath = getRealmDBPath({ osuDataDir });
1746
- if (!realmDBPath) {
1747
- throw new Error("Realm DB not found");
1748
- }
1749
- const {
1750
- Realm: Realm8,
1751
- formatLazerSchemaCompatibilityError: formatLazerSchemaCompatibilityError2,
1752
- getBeatmapSets: getBeatmapSets2,
1753
- getLazerDB: getLazerDB2,
1754
- inspectLazerSchema: inspectLazerSchema2
1755
- } = await loadRealmDependencies();
1756
- Realm8.flags.ALLOW_CLEAR_TEST_STATE = true;
1757
- const realm = await getLazerDB2(realmDBPath);
1758
- try {
1759
- const schemaReport = inspectLazerSchema2(realm);
1760
- if (!schemaReport.compatible) {
1761
- throw new Error(formatLazerSchemaCompatibilityError2(schemaReport));
1762
- }
1763
- return buildPlaylist(getBeatmapSets2(realm), osuDataDir);
1764
- } finally {
1765
- realm.close();
1766
- }
1767
- }
1768
2517
  async function deleteTrackFromCollection(track, osuDataDir) {
1769
- const { deleteBeatmapSet: deleteBeatmapSet2 } = await loadRealmDependencies();
1770
- await deleteBeatmapSet2(track, osuDataDir);
2518
+ await deleteBeatmapSetFromLazer(track, osuDataDir);
1771
2519
  }
1772
- async function runTuiPlayer(playlist, osuDataDir, loop) {
2520
+ async function runTuiPlayer(playlist, osuDataDir, loop, shuffle) {
1773
2521
  const { revealFile: revealFile2 } = await Promise.resolve().then(() => (init_mod(), exports_mod));
1774
2522
  const backend = new MpvPlayerBackend;
1775
2523
  const session = new PlaylistPlayerSession(playlist, backend, {
1776
2524
  deleteTrack: (track) => deleteTrackFromCollection(track, osuDataDir),
1777
2525
  loop,
1778
- reloadPlaylist: () => createPlaylist(osuDataDir),
1779
- revealTrack: (track) => revealFile2(track.path)
2526
+ reloadPlaylist: () => loadPlaylistFromLazer(osuDataDir),
2527
+ revealTrack: (track) => revealFile2(track.path),
2528
+ shuffle
1780
2529
  });
1781
2530
  const terminal = new ProcessTerminal;
1782
2531
  const tui = new TUI(terminal);
@@ -1807,6 +2556,30 @@ async function runTuiPlayer(playlist, osuDataDir, loop) {
1807
2556
  await session.dispose();
1808
2557
  }
1809
2558
  }
2559
+ async function runApiMode(osuDataDir, port) {
2560
+ const catalogStore = await createLazerCatalogStore(osuDataDir);
2561
+ const server = await startApiServer({
2562
+ catalogStore,
2563
+ port
2564
+ });
2565
+ console.log(`[INFO] osu-play API listening at ${server.url}`);
2566
+ console.log("[INFO] In Kopuz, add a Custom server with this URL and any non-empty username/password.");
2567
+ console.log("[INFO] Press Ctrl+C to stop.");
2568
+ let resolveShutdown;
2569
+ const shutdown = new Promise((resolve) => {
2570
+ resolveShutdown = resolve;
2571
+ });
2572
+ const handleSignal = () => resolveShutdown?.();
2573
+ process.once("SIGINT", handleSignal);
2574
+ process.once("SIGTERM", handleSignal);
2575
+ try {
2576
+ await shutdown;
2577
+ } finally {
2578
+ process.off("SIGINT", handleSignal);
2579
+ process.off("SIGTERM", handleSignal);
2580
+ await server.close();
2581
+ }
2582
+ }
1810
2583
  async function main() {
1811
2584
  const argv = await getArgs();
1812
2585
  if (argv.reload) {
@@ -1815,16 +2588,20 @@ async function main() {
1815
2588
  if (argv.configDir) {
1816
2589
  console.log("[INFO] `--configDir` is deprecated and ignored because osu-play no longer copies the Realm DB.");
1817
2590
  }
1818
- const playlist = await createPlaylist(argv.osuDataDir);
2591
+ if (argv.api) {
2592
+ await runApiMode(argv.osuDataDir, argv.apiPort);
2593
+ return;
2594
+ }
2595
+ const playlist = await loadPlaylistFromLazer(argv.osuDataDir);
1819
2596
  if (argv.exportPlaylist) {
1820
2597
  const playlistContents = playlist.map((track) => track.path).join(`
1821
2598
  `);
1822
2599
  writeFileSync(argv.exportPlaylist, playlistContents);
1823
2600
  console.log(`[INFO] Exported ${playlist.length} tracks to ${argv.exportPlaylist}.`);
1824
- console.log(`[INFO] Use something like \`mpv --playlist=${path3.resolve(argv.exportPlaylist)}\` to play the playlist.`);
2601
+ console.log(`[INFO] Use something like \`mpv --playlist=${path4.resolve(argv.exportPlaylist)}\` to play the playlist.`);
1825
2602
  return;
1826
2603
  }
1827
- await runTuiPlayer(playlist, argv.osuDataDir, argv.loop);
2604
+ await runTuiPlayer(playlist, argv.osuDataDir, argv.loop, argv.shuffle);
1828
2605
  }
1829
2606
 
1830
2607
  // src/cli.ts
@@ -1835,4 +2612,4 @@ main().then(() => {
1835
2612
  process.exit(1);
1836
2613
  });
1837
2614
 
1838
- //# debugId=E1B92021231DEE2064756E2164756E21
2615
+ //# debugId=7C34DADA116CE55564756E2164756E21