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.cjs CHANGED
@@ -53,7 +53,7 @@ __export(exports_mod, {
53
53
  getConfigDir: () => getConfigDir
54
54
  });
55
55
  function revealFile(filePath) {
56
- const command = process.platform === "darwin" ? { args: ["-R", filePath], executable: "open" } : process.platform === "win32" ? { args: ["/select,", filePath], executable: "explorer.exe" } : { args: [import_node_path.default.dirname(filePath)], executable: "xdg-open" };
56
+ const command = process.platform === "darwin" ? { args: ["-R", filePath], executable: "open" } : process.platform === "win32" ? { args: ["/select,", filePath], executable: "explorer.exe" } : { args: [import_node_path2.default.dirname(filePath)], executable: "xdg-open" };
57
57
  return new Promise((resolve, reject) => {
58
58
  const child = import_node_child_process.spawn(command.executable, command.args, {
59
59
  detached: true,
@@ -76,13 +76,13 @@ function getDataDir() {
76
76
  return xdg;
77
77
  const home = process.env.HOME;
78
78
  if (home)
79
- return import_node_path.default.join(home, ".local", "share");
79
+ return import_node_path2.default.join(home, ".local", "share");
80
80
  break;
81
81
  }
82
82
  case "darwin": {
83
83
  const home = process.env.HOME;
84
84
  if (home)
85
- return import_node_path.default.join(home, "Library", "Application Support");
85
+ return import_node_path2.default.join(home, "Library", "Application Support");
86
86
  break;
87
87
  }
88
88
  case "win32":
@@ -100,13 +100,13 @@ function getConfigDir() {
100
100
  return xdg;
101
101
  const home = process.env.HOME;
102
102
  if (home)
103
- return import_node_path.default.join(home, ".config");
103
+ return import_node_path2.default.join(home, ".config");
104
104
  break;
105
105
  }
106
106
  case "darwin": {
107
107
  const home = process.env.HOME;
108
108
  if (home)
109
- return import_node_path.default.join(home, "Library", "Preferences");
109
+ return import_node_path2.default.join(home, "Library", "Preferences");
110
110
  break;
111
111
  }
112
112
  case "win32":
@@ -115,28 +115,28 @@ function getConfigDir() {
115
115
  return;
116
116
  }
117
117
  function getDefaultOsuDataDir() {
118
- return import_node_path.default.join(getDataDir() ?? ".", "osu");
118
+ return import_node_path2.default.join(getDataDir() ?? ".", "osu");
119
119
  }
120
120
  function getDefaultConfigPath(appName = "osu-play") {
121
- return import_node_path.default.join(getConfigDir() ?? ".", appName);
121
+ return import_node_path2.default.join(getConfigDir() ?? ".", appName);
122
122
  }
123
123
  function hashedFilePath(hash, osuDataDir = getDefaultOsuDataDir()) {
124
- return import_node_path.default.join(osuDataDir, "files", hash.slice(0, 1), hash.slice(0, 2), hash);
124
+ return import_node_path2.default.join(osuDataDir, "files", hash.slice(0, 1), hash.slice(0, 2), hash);
125
125
  }
126
126
  function getRealmDBPath(legacyAppConfigDirOrOptions = {}, maybeOptions = {}) {
127
127
  const options = typeof legacyAppConfigDirOrOptions === "string" ? maybeOptions : legacyAppConfigDirOrOptions;
128
128
  const osuDataDir = options.osuDataDir ?? getDefaultOsuDataDir();
129
- const osuDBPath = import_node_path.default.join(osuDataDir, "client.realm");
130
- if (!import_node_fs.existsSync(osuDBPath)) {
129
+ const osuDBPath = import_node_path2.default.join(osuDataDir, "client.realm");
130
+ if (!import_node_fs2.existsSync(osuDBPath)) {
131
131
  console.log(`[getRealmDBPath]: ${osuDBPath} not found`);
132
132
  return null;
133
133
  }
134
134
  return osuDBPath;
135
135
  }
136
- var import_node_path, import_node_fs, import_node_child_process;
136
+ var import_node_path2, import_node_fs2, import_node_child_process;
137
137
  var init_mod = __esm(() => {
138
- import_node_path = __toESM(require("node:path"));
139
- import_node_fs = require("node:fs");
138
+ import_node_path2 = __toESM(require("node:path"));
139
+ import_node_fs2 = require("node:fs");
140
140
  import_node_child_process = require("node:child_process");
141
141
  });
142
142
 
@@ -529,12 +529,615 @@ var init_mod3 = __esm(() => {
529
529
  });
530
530
 
531
531
  // src/core/cli/main.ts
532
- var import_node_path3 = __toESM(require("node:path"));
533
- var import_node_fs3 = require("node:fs");
532
+ var import_node_path4 = __toESM(require("node:path"));
533
+ var import_node_fs4 = require("node:fs");
534
534
  var import_pi_tui2 = require("@earendil-works/pi-tui");
535
535
  var import_yargs = __toESM(require("yargs/yargs"));
536
536
  var import_helpers = require("yargs/helpers");
537
537
 
538
+ // src/core/api/server.ts
539
+ var import_node_fs = require("node:fs");
540
+ var import_promises = require("node:fs/promises");
541
+ var import_node_http = require("node:http");
542
+ var import_node_path = __toESM(require("node:path"));
543
+ var API_VERSION = "1.16.1";
544
+ var SERVER_VERSION = "1.4.0";
545
+ var DEFAULT_HOST = "127.0.0.1";
546
+ var DEFAULT_PORT = 4533;
547
+ function responseBase(status) {
548
+ return {
549
+ openSubsonic: true,
550
+ serverVersion: SERVER_VERSION,
551
+ status,
552
+ type: "osu-play",
553
+ version: API_VERSION
554
+ };
555
+ }
556
+ function sendJson(response, body, statusCode = 200) {
557
+ const encoded = JSON.stringify(body);
558
+ response.writeHead(statusCode, {
559
+ "Cache-Control": "no-store",
560
+ "Content-Length": Buffer.byteLength(encoded),
561
+ "Content-Type": "application/json; charset=utf-8"
562
+ });
563
+ response.end(encoded);
564
+ }
565
+ function sendSuccess(response, data = {}) {
566
+ sendJson(response, {
567
+ "subsonic-response": {
568
+ ...responseBase("ok"),
569
+ ...data
570
+ }
571
+ });
572
+ }
573
+ function sendFailure(response, error, statusCode = 200) {
574
+ sendJson(response, {
575
+ "subsonic-response": {
576
+ ...responseBase("failed"),
577
+ error
578
+ }
579
+ }, statusCode);
580
+ }
581
+ function requiredParameter(response, params, name) {
582
+ const value = params.get(name)?.trim();
583
+ if (!value) {
584
+ sendFailure(response, {
585
+ code: 10,
586
+ message: `Required parameter '${name}' is missing.`
587
+ });
588
+ return null;
589
+ }
590
+ return value;
591
+ }
592
+ function hasValidCredentials(params) {
593
+ const username = params.get("u")?.trim();
594
+ const token = params.get("t")?.trim();
595
+ const salt = params.get("s")?.trim();
596
+ return Boolean(username && token && salt && /^[a-f0-9]{32}$/i.test(token) && params.get("f") === "json" && params.get("c")?.trim() && params.get("v")?.trim());
597
+ }
598
+ function parseNonNegativeInteger(value, fallback, maximum) {
599
+ if (value === null) {
600
+ return fallback;
601
+ }
602
+ if (!/^\d+$/.test(value)) {
603
+ return null;
604
+ }
605
+ const parsed = Number(value);
606
+ if (!Number.isSafeInteger(parsed) || parsed < 0) {
607
+ return null;
608
+ }
609
+ return maximum === undefined ? parsed : Math.min(parsed, maximum);
610
+ }
611
+ function albumPayload(album) {
612
+ return {
613
+ artist: album.artist,
614
+ ...album.coverArt ? { coverArt: album.coverArt } : {},
615
+ id: album.id,
616
+ name: album.name
617
+ };
618
+ }
619
+ function songPayload(song) {
620
+ return {
621
+ album: song.album,
622
+ albumId: song.albumId,
623
+ artist: song.artist,
624
+ ...song.coverArt ? { coverArt: song.coverArt } : {},
625
+ ...song.duration ? { duration: song.duration } : {},
626
+ id: song.id,
627
+ title: song.title,
628
+ track: song.track
629
+ };
630
+ }
631
+ function artistIndex(catalog) {
632
+ const groups = new Map;
633
+ for (const artist of catalog.artists) {
634
+ const firstCharacter = artist.name.trim().charAt(0).toUpperCase();
635
+ const name = /^[A-Z]$/.test(firstCharacter) ? firstCharacter : "#";
636
+ const group = groups.get(name) ?? [];
637
+ group.push({
638
+ ...artist.coverArt ? { coverArt: artist.coverArt } : {},
639
+ id: artist.id,
640
+ name: artist.name
641
+ });
642
+ groups.set(name, group);
643
+ }
644
+ return [...groups.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([name, artist]) => ({ artist, name }));
645
+ }
646
+ function contentType(fileName) {
647
+ switch (import_node_path.default.extname(fileName).toLowerCase()) {
648
+ case ".aac":
649
+ return "audio/aac";
650
+ case ".flac":
651
+ return "audio/flac";
652
+ case ".jpeg":
653
+ case ".jpg":
654
+ return "image/jpeg";
655
+ case ".m4a":
656
+ case ".mp4":
657
+ return "audio/mp4";
658
+ case ".mp3":
659
+ return "audio/mpeg";
660
+ case ".ogg":
661
+ case ".oga":
662
+ case ".opus":
663
+ return "audio/ogg";
664
+ case ".png":
665
+ return "image/png";
666
+ case ".wav":
667
+ return "audio/wav";
668
+ case ".webm":
669
+ return "audio/webm";
670
+ case ".webp":
671
+ return "image/webp";
672
+ default:
673
+ return "application/octet-stream";
674
+ }
675
+ }
676
+ function parseRange(rangeHeader, size) {
677
+ const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
678
+ if (!match || !match[1] && !match[2]) {
679
+ return null;
680
+ }
681
+ if (!match[1]) {
682
+ const suffixLength = Number(match[2]);
683
+ if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) {
684
+ return null;
685
+ }
686
+ return {
687
+ end: size - 1,
688
+ start: Math.max(0, size - suffixLength)
689
+ };
690
+ }
691
+ const start = Number(match[1]);
692
+ const requestedEnd = match[2] ? Number(match[2]) : size - 1;
693
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(requestedEnd) || start < 0 || requestedEnd < start || start >= size) {
694
+ return null;
695
+ }
696
+ return {
697
+ end: Math.min(requestedEnd, size - 1),
698
+ start
699
+ };
700
+ }
701
+ async function serveMedia(request, response, media, etag) {
702
+ let file;
703
+ try {
704
+ file = await import_promises.stat(media.path);
705
+ } catch {
706
+ sendFailure(response, {
707
+ code: 70,
708
+ message: "Requested media is no longer available."
709
+ }, 404);
710
+ return;
711
+ }
712
+ if (!file.isFile()) {
713
+ sendFailure(response, {
714
+ code: 70,
715
+ message: "Requested media is not a file."
716
+ }, 404);
717
+ return;
718
+ }
719
+ const commonHeaders = {
720
+ "Accept-Ranges": "bytes",
721
+ "Cache-Control": "public, max-age=31536000, immutable",
722
+ "Content-Type": contentType(media.fileName),
723
+ ETag: `"${etag}"`
724
+ };
725
+ const rangeHeader = request.headers.range;
726
+ const range = rangeHeader ? parseRange(rangeHeader, file.size) : undefined;
727
+ if (rangeHeader && !range) {
728
+ response.writeHead(416, {
729
+ ...commonHeaders,
730
+ "Content-Range": `bytes */${file.size}`
731
+ });
732
+ response.end();
733
+ return;
734
+ }
735
+ const start = range?.start ?? 0;
736
+ const end = range?.end ?? file.size - 1;
737
+ const contentLength = Math.max(0, end - start + 1);
738
+ response.writeHead(range ? 206 : 200, {
739
+ ...commonHeaders,
740
+ "Content-Length": contentLength,
741
+ ...range ? { "Content-Range": `bytes ${start}-${end}/${file.size}` } : {}
742
+ });
743
+ if (request.method === "HEAD" || file.size === 0) {
744
+ response.end();
745
+ return;
746
+ }
747
+ const stream = import_node_fs.createReadStream(media.path, { end, start });
748
+ const destroyStream = () => stream.destroy();
749
+ response.once("close", destroyStream);
750
+ stream.once("error", (error) => {
751
+ response.destroy(error);
752
+ });
753
+ stream.once("close", () => {
754
+ response.off("close", destroyStream);
755
+ });
756
+ stream.pipe(response);
757
+ }
758
+ async function routeRequest(request, response, catalogStore) {
759
+ const requestUrl = new URL(request.url ?? "/", `http://${DEFAULT_HOST}`);
760
+ const match = /^\/rest\/([A-Za-z0-9]+)\.view$/.exec(requestUrl.pathname);
761
+ if (!match) {
762
+ sendFailure(response, {
763
+ code: 0,
764
+ message: "Unsupported API endpoint."
765
+ }, 404);
766
+ return;
767
+ }
768
+ const endpoint = match[1];
769
+ const isMedia = endpoint === "stream" || endpoint === "getCoverArt";
770
+ const allowedMethod = request.method === "GET" || isMedia && request.method === "HEAD";
771
+ if (!allowedMethod) {
772
+ response.setHeader("Allow", isMedia ? "GET, HEAD" : "GET");
773
+ sendFailure(response, {
774
+ code: 0,
775
+ message: "Unsupported HTTP method."
776
+ }, 405);
777
+ return;
778
+ }
779
+ if (!hasValidCredentials(requestUrl.searchParams)) {
780
+ sendFailure(response, {
781
+ code: 40,
782
+ message: "Wrong username or password."
783
+ });
784
+ return;
785
+ }
786
+ if (endpoint === "getArtists" || endpoint === "getAlbumList2") {
787
+ const offset = endpoint === "getAlbumList2" ? parseNonNegativeInteger(requestUrl.searchParams.get("offset"), 0) : 0;
788
+ if (offset === 0) {
789
+ await catalogStore.refreshIfChanged();
790
+ }
791
+ }
792
+ const catalog = catalogStore.getCatalog();
793
+ switch (endpoint) {
794
+ case "ping":
795
+ sendSuccess(response);
796
+ return;
797
+ case "getArtists":
798
+ sendSuccess(response, {
799
+ artists: {
800
+ index: artistIndex(catalog)
801
+ }
802
+ });
803
+ return;
804
+ case "getAlbumList2": {
805
+ if (requestUrl.searchParams.get("type") !== "alphabeticalByName") {
806
+ sendFailure(response, {
807
+ code: 10,
808
+ message: "Only type=alphabeticalByName is supported."
809
+ });
810
+ return;
811
+ }
812
+ const offset = parseNonNegativeInteger(requestUrl.searchParams.get("offset"), 0);
813
+ const size = parseNonNegativeInteger(requestUrl.searchParams.get("size"), 10, 500);
814
+ if (offset === null || size === null) {
815
+ sendFailure(response, {
816
+ code: 10,
817
+ message: "Album offset and size must be non-negative integers."
818
+ });
819
+ return;
820
+ }
821
+ sendSuccess(response, {
822
+ albumList2: {
823
+ album: catalog.albums.slice(offset, offset + size).map(albumPayload)
824
+ }
825
+ });
826
+ return;
827
+ }
828
+ case "getAlbum": {
829
+ const id = requiredParameter(response, requestUrl.searchParams, "id");
830
+ if (!id)
831
+ return;
832
+ const album = catalog.albumsById.get(id);
833
+ if (!album) {
834
+ sendFailure(response, {
835
+ code: 70,
836
+ message: "Album not found."
837
+ });
838
+ return;
839
+ }
840
+ sendSuccess(response, {
841
+ album: {
842
+ ...albumPayload(album),
843
+ song: album.songs.map(songPayload)
844
+ }
845
+ });
846
+ return;
847
+ }
848
+ case "stream": {
849
+ const id = requiredParameter(response, requestUrl.searchParams, "id");
850
+ if (!id)
851
+ return;
852
+ const song = catalog.songsById.get(id);
853
+ if (!song) {
854
+ sendFailure(response, {
855
+ code: 70,
856
+ message: "Song not found."
857
+ }, 404);
858
+ return;
859
+ }
860
+ await serveMedia(request, response, {
861
+ fileName: song.fileName,
862
+ path: song.path
863
+ }, song.id);
864
+ return;
865
+ }
866
+ case "getCoverArt": {
867
+ const id = requiredParameter(response, requestUrl.searchParams, "id");
868
+ if (!id)
869
+ return;
870
+ const cover = catalog.coversById.get(id);
871
+ if (!cover) {
872
+ sendFailure(response, {
873
+ code: 70,
874
+ message: "Cover art not found."
875
+ }, 404);
876
+ return;
877
+ }
878
+ await serveMedia(request, response, cover, id);
879
+ return;
880
+ }
881
+ case "getPlaylists":
882
+ sendSuccess(response, {
883
+ playlists: {
884
+ playlist: []
885
+ }
886
+ });
887
+ return;
888
+ case "getStarred2":
889
+ sendSuccess(response, {
890
+ starred2: {
891
+ song: []
892
+ }
893
+ });
894
+ return;
895
+ case "scrobble": {
896
+ const id = requiredParameter(response, requestUrl.searchParams, "id");
897
+ if (!id)
898
+ return;
899
+ if (!catalog.songsById.has(id)) {
900
+ sendFailure(response, {
901
+ code: 70,
902
+ message: "Song not found."
903
+ });
904
+ return;
905
+ }
906
+ sendSuccess(response);
907
+ return;
908
+ }
909
+ case "getLyricsBySongId":
910
+ sendSuccess(response, {
911
+ lyricsList: {
912
+ structuredLyrics: []
913
+ }
914
+ });
915
+ return;
916
+ case "getLyrics":
917
+ sendSuccess(response, {
918
+ lyrics: {
919
+ value: ""
920
+ }
921
+ });
922
+ return;
923
+ case "getPlaylist":
924
+ sendFailure(response, {
925
+ code: 70,
926
+ message: "Playlist not found; osu-play API mode is read-only."
927
+ });
928
+ return;
929
+ case "createPlaylist":
930
+ case "updatePlaylist":
931
+ case "star":
932
+ case "unstar":
933
+ sendFailure(response, {
934
+ code: 0,
935
+ message: "This operation is not supported by read-only osu-play API mode."
936
+ });
937
+ return;
938
+ default:
939
+ sendFailure(response, {
940
+ code: 0,
941
+ message: `Endpoint '${endpoint}.view' is not supported.`
942
+ });
943
+ }
944
+ }
945
+ async function startApiServer(options) {
946
+ const host = options.host ?? DEFAULT_HOST;
947
+ const requestedPort = options.port ?? DEFAULT_PORT;
948
+ const server = import_node_http.createServer((request, response) => {
949
+ routeRequest(request, response, options.catalogStore).catch((error) => {
950
+ if (!response.headersSent) {
951
+ sendFailure(response, {
952
+ code: 0,
953
+ message: "Internal server error."
954
+ }, 500);
955
+ } else {
956
+ response.destroy(error instanceof Error ? error : new Error(String(error)));
957
+ }
958
+ });
959
+ });
960
+ server.requestTimeout = 30000;
961
+ server.headersTimeout = 1e4;
962
+ await new Promise((resolve, reject) => {
963
+ const handleError = (error) => {
964
+ server.off("listening", handleListening);
965
+ reject(error);
966
+ };
967
+ const handleListening = () => {
968
+ server.off("error", handleError);
969
+ resolve();
970
+ };
971
+ server.once("error", handleError);
972
+ server.once("listening", handleListening);
973
+ server.listen(requestedPort, host);
974
+ });
975
+ const address = server.address();
976
+ if (!address || typeof address === "string") {
977
+ await new Promise((resolve) => server.close(() => resolve()));
978
+ throw new Error("Could not determine the API server address.");
979
+ }
980
+ const port = address.port;
981
+ return {
982
+ async close() {
983
+ if (!server.listening) {
984
+ return;
985
+ }
986
+ await new Promise((resolve, reject) => {
987
+ server.close((error) => {
988
+ if (error)
989
+ reject(error);
990
+ else
991
+ resolve();
992
+ });
993
+ server.closeIdleConnections();
994
+ });
995
+ },
996
+ host,
997
+ port,
998
+ url: `http://${host}:${port}`
999
+ };
1000
+ }
1001
+
1002
+ // src/core/lazer/library.ts
1003
+ var import_promises2 = require("node:fs/promises");
1004
+
1005
+ // src/core/api/catalog.ts
1006
+ init_mod();
1007
+ var import_node_crypto = require("node:crypto");
1008
+ function stableId(kind, value) {
1009
+ return import_node_crypto.createHash("sha256").update(`${kind}\x00${value}`).digest("hex");
1010
+ }
1011
+ function normalizedParts(parts) {
1012
+ return [...new Set(parts.map((part) => part?.trim()).filter(Boolean))];
1013
+ }
1014
+ function formatMetadataText(primary, unicode, fallback) {
1015
+ return normalizedParts([primary, unicode]).join(" / ") || fallback;
1016
+ }
1017
+ function getNamedFile(fileName, beatmapSet) {
1018
+ if (!fileName) {
1019
+ return null;
1020
+ }
1021
+ for (const file of beatmapSet.Files) {
1022
+ if (file.Filename === fileName && file.File?.Hash) {
1023
+ return {
1024
+ fileName,
1025
+ hash: file.File.Hash
1026
+ };
1027
+ }
1028
+ }
1029
+ return null;
1030
+ }
1031
+ function beatmapSetKey(beatmapSet) {
1032
+ return String(beatmapSet.ID ?? beatmapSet.Hash ?? "");
1033
+ }
1034
+ function compareText(left, right) {
1035
+ return left.localeCompare(right, undefined, {
1036
+ sensitivity: "base",
1037
+ usage: "sort"
1038
+ });
1039
+ }
1040
+ function buildMusicCatalog(beatmapSets, osuDataDir) {
1041
+ const albums = [];
1042
+ const coversById = new Map;
1043
+ const seenAudioHashes = new Set;
1044
+ const sortedSets = [...beatmapSets].filter((beatmapSet) => !beatmapSet.DeletePending).sort((left, right) => compareText(beatmapSetKey(left), beatmapSetKey(right)));
1045
+ for (const beatmapSet of sortedSets) {
1046
+ const pendingByHash = new Map;
1047
+ let albumCoverArt;
1048
+ for (const beatmap of beatmapSet.Beatmaps) {
1049
+ const metadata = beatmap.Metadata;
1050
+ const audio = getNamedFile(metadata.AudioFile, beatmapSet);
1051
+ if (!audio) {
1052
+ continue;
1053
+ }
1054
+ const background = getNamedFile(metadata.BackgroundFile, beatmapSet);
1055
+ if (background) {
1056
+ coversById.set(background.hash, {
1057
+ fileName: background.fileName,
1058
+ path: hashedFilePath(background.hash, osuDataDir)
1059
+ });
1060
+ albumCoverArt ??= background.hash;
1061
+ }
1062
+ const durationMs = Math.max(0, beatmap.Length ?? 0);
1063
+ const existing = pendingByHash.get(audio.hash);
1064
+ if (existing) {
1065
+ existing.durationMs = Math.max(existing.durationMs, durationMs);
1066
+ existing.coverArt ??= background?.hash;
1067
+ continue;
1068
+ }
1069
+ pendingByHash.set(audio.hash, {
1070
+ artist: formatMetadataText(metadata.Artist, metadata.ArtistUnicode, "Unknown Artist"),
1071
+ coverArt: background?.hash,
1072
+ durationMs,
1073
+ fileName: audio.fileName,
1074
+ hash: audio.hash,
1075
+ title: formatMetadataText(metadata.Title, metadata.TitleUnicode, "Unknown Title")
1076
+ });
1077
+ }
1078
+ const retained = [...pendingByHash.values()].filter((song) => !seenAudioHashes.has(song.hash));
1079
+ if (retained.length === 0) {
1080
+ continue;
1081
+ }
1082
+ for (const song of retained) {
1083
+ seenAudioHashes.add(song.hash);
1084
+ }
1085
+ const firstSong = retained[0];
1086
+ const setKey = beatmapSetKey(beatmapSet) || firstSong.hash;
1087
+ const albumId = stableId("album", setKey);
1088
+ const albumName = firstSong.title;
1089
+ const albumArtist = firstSong.artist;
1090
+ const coverArt = albumCoverArt ?? firstSong.coverArt;
1091
+ const songs = retained.map((song, index) => {
1092
+ const duration = Math.round(song.durationMs / 1000);
1093
+ return {
1094
+ album: albumName,
1095
+ albumId,
1096
+ artist: song.artist,
1097
+ ...coverArt ? { coverArt } : {},
1098
+ ...duration > 0 ? { duration } : {},
1099
+ fileName: song.fileName,
1100
+ id: song.hash,
1101
+ path: hashedFilePath(song.hash, osuDataDir),
1102
+ title: song.title,
1103
+ track: index + 1
1104
+ };
1105
+ });
1106
+ albums.push({
1107
+ artist: albumArtist,
1108
+ ...coverArt ? { coverArt } : {},
1109
+ id: albumId,
1110
+ name: albumName,
1111
+ songs
1112
+ });
1113
+ }
1114
+ albums.sort((left, right) => compareText(left.name, right.name) || left.id.localeCompare(right.id));
1115
+ const albumsById = new Map(albums.map((album) => [album.id, album]));
1116
+ const songsById = new Map(albums.flatMap((album) => album.songs.map((song) => [song.id, song])));
1117
+ const artistsByName = new Map;
1118
+ for (const album of albums) {
1119
+ const normalizedName = album.artist.trim().toLocaleLowerCase();
1120
+ const existing = artistsByName.get(normalizedName);
1121
+ if (existing) {
1122
+ existing.coverArt ??= album.coverArt;
1123
+ continue;
1124
+ }
1125
+ artistsByName.set(normalizedName, {
1126
+ ...album.coverArt ? { coverArt: album.coverArt } : {},
1127
+ id: stableId("artist", normalizedName),
1128
+ name: album.artist
1129
+ });
1130
+ }
1131
+ const artists = [...artistsByName.values()].sort((left, right) => compareText(left.name, right.name) || left.id.localeCompare(right.id));
1132
+ return {
1133
+ albums,
1134
+ albumsById,
1135
+ artists,
1136
+ coversById,
1137
+ songsById
1138
+ };
1139
+ }
1140
+
538
1141
  // src/core/playlist/mod.ts
539
1142
  init_mod();
540
1143
  function uniqueParts(parts) {
@@ -579,13 +1182,124 @@ function buildPlaylist(beatmapSets, osuDataDir) {
579
1182
  return playlist;
580
1183
  }
581
1184
 
1185
+ // src/core/lazer/library.ts
1186
+ init_mod();
1187
+ function formatRealmLoadError(error) {
1188
+ return [
1189
+ "Realm native bindings could not be loaded.",
1190
+ `Current Node runtime: ${process.version}.`,
1191
+ "To repair Realm bindings in this project, run `bun run repair:realm` (or `bun run setup` to reinstall dependencies).",
1192
+ "If you installed dependencies on Node 22, switch to Node 20 LTS and rerun the repair command.",
1193
+ `Original error: ${error instanceof Error ? error.message : String(error)}`
1194
+ ].join(`
1195
+ `);
1196
+ }
1197
+ async function loadRealmDependencies() {
1198
+ try {
1199
+ const [{ default: Realm8 }, lazerModule] = await Promise.all([
1200
+ import("realm"),
1201
+ Promise.resolve().then(() => (init_mod3(), exports_mod2))
1202
+ ]);
1203
+ return {
1204
+ Realm: Realm8,
1205
+ ...lazerModule
1206
+ };
1207
+ } catch (error) {
1208
+ throw new Error(formatRealmLoadError(error), { cause: error });
1209
+ }
1210
+ }
1211
+ async function projectLazerLibrary(osuDataDir, projector) {
1212
+ const realmDBPath = getRealmDBPath({ osuDataDir });
1213
+ if (!realmDBPath) {
1214
+ throw new Error("Realm DB not found");
1215
+ }
1216
+ const {
1217
+ Realm: Realm8,
1218
+ formatLazerSchemaCompatibilityError: formatLazerSchemaCompatibilityError2,
1219
+ getBeatmapSets: getBeatmapSets2,
1220
+ getLazerDB: getLazerDB2,
1221
+ inspectLazerSchema: inspectLazerSchema2
1222
+ } = await loadRealmDependencies();
1223
+ Realm8.flags.ALLOW_CLEAR_TEST_STATE = true;
1224
+ const realm = await getLazerDB2(realmDBPath);
1225
+ try {
1226
+ const schemaReport = inspectLazerSchema2(realm);
1227
+ if (!schemaReport.compatible) {
1228
+ throw new Error(formatLazerSchemaCompatibilityError2(schemaReport));
1229
+ }
1230
+ return projector(getBeatmapSets2(realm));
1231
+ } finally {
1232
+ realm.close();
1233
+ }
1234
+ }
1235
+ function loadPlaylistFromLazer(osuDataDir) {
1236
+ return projectLazerLibrary(osuDataDir, (beatmapSets) => buildPlaylist(beatmapSets, osuDataDir));
1237
+ }
1238
+ function loadMusicCatalogFromLazer(osuDataDir) {
1239
+ return projectLazerLibrary(osuDataDir, (beatmapSets) => buildMusicCatalog(beatmapSets, osuDataDir));
1240
+ }
1241
+ async function deleteBeatmapSetFromLazer(track, osuDataDir) {
1242
+ const { deleteBeatmapSet: deleteBeatmapSet2 } = await loadRealmDependencies();
1243
+ await deleteBeatmapSet2(track, osuDataDir);
1244
+ }
1245
+ async function createRefreshingCatalogStore(options) {
1246
+ let catalog = await options.loadCatalog();
1247
+ let signature = await options.getSignature();
1248
+ let refreshPromise = null;
1249
+ return {
1250
+ getCatalog() {
1251
+ return catalog;
1252
+ },
1253
+ async refreshIfChanged() {
1254
+ if (refreshPromise) {
1255
+ return refreshPromise;
1256
+ }
1257
+ refreshPromise = (async () => {
1258
+ try {
1259
+ const nextSignature = await options.getSignature();
1260
+ if (nextSignature === signature) {
1261
+ return catalog;
1262
+ }
1263
+ const nextCatalog = await options.loadCatalog();
1264
+ catalog = nextCatalog;
1265
+ signature = nextSignature;
1266
+ } catch (error) {
1267
+ options.onRefreshError?.(error);
1268
+ }
1269
+ return catalog;
1270
+ })();
1271
+ try {
1272
+ return await refreshPromise;
1273
+ } finally {
1274
+ refreshPromise = null;
1275
+ }
1276
+ }
1277
+ };
1278
+ }
1279
+ async function createLazerCatalogStore(osuDataDir, onRefreshError = (error) => {
1280
+ console.warn(`[WARN] Could not refresh the osu!lazer catalog; continuing with the last good snapshot: ${error instanceof Error ? error.message : String(error)}`);
1281
+ }) {
1282
+ const realmDBPath = getRealmDBPath({ osuDataDir });
1283
+ if (!realmDBPath) {
1284
+ throw new Error("Realm DB not found");
1285
+ }
1286
+ return createRefreshingCatalogStore({
1287
+ async getSignature() {
1288
+ const file = await import_promises2.stat(realmDBPath);
1289
+ return `${file.mtimeMs}:${file.size}`;
1290
+ },
1291
+ loadCatalog: () => loadMusicCatalogFromLazer(osuDataDir),
1292
+ onRefreshError
1293
+ });
1294
+ }
1295
+
582
1296
  // src/core/player/mpv.ts
583
1297
  var import_node_child_process2 = require("node:child_process");
584
- var import_node_fs2 = require("node:fs");
1298
+ var import_node_fs3 = require("node:fs");
585
1299
  var import_node_net = __toESM(require("node:net"));
586
1300
  var import_node_os = __toESM(require("node:os"));
587
- var import_node_path2 = __toESM(require("node:path"));
588
- var import_promises = require("node:timers/promises");
1301
+ var import_node_path3 = __toESM(require("node:path"));
1302
+ var import_promises3 = require("node:timers/promises");
589
1303
  function getErrorCode(error) {
590
1304
  if (error && typeof error === "object" && "code" in error) {
591
1305
  const { code } = error;
@@ -606,7 +1320,7 @@ function createSocketPath() {
606
1320
  if (process.platform === "win32") {
607
1321
  return `\\\\.\\pipe\\${id}`;
608
1322
  }
609
- return import_node_path2.default.join(import_node_os.default.tmpdir(), `${id}.sock`);
1323
+ return import_node_path3.default.join(import_node_os.default.tmpdir(), `${id}.sock`);
610
1324
  }
611
1325
  function isControlText(value) {
612
1326
  return [...value].some((character) => {
@@ -786,7 +1500,7 @@ ${detail}` : "";
786
1500
  throw error;
787
1501
  }
788
1502
  }
789
- await import_promises.setTimeout(50);
1503
+ await import_promises3.setTimeout(50);
790
1504
  }
791
1505
  throw new Error(this.formatStartupError("Timed out waiting for mpv IPC to become ready."));
792
1506
  }
@@ -963,7 +1677,7 @@ ${detail}` : "";
963
1677
  new Promise((resolve) => {
964
1678
  processHandle.once("exit", () => resolve());
965
1679
  }),
966
- import_promises.setTimeout(1000)
1680
+ import_promises3.setTimeout(1000)
967
1681
  ]);
968
1682
  if (processHandle.exitCode === null) {
969
1683
  processHandle.kill("SIGKILL");
@@ -975,8 +1689,8 @@ ${detail}` : "";
975
1689
  if (process.platform === "win32") {
976
1690
  return;
977
1691
  }
978
- if (import_node_fs2.existsSync(this.socketPath)) {
979
- import_node_fs2.rmSync(this.socketPath, { force: true });
1692
+ if (import_node_fs3.existsSync(this.socketPath)) {
1693
+ import_node_fs3.rmSync(this.socketPath, { force: true });
980
1694
  }
981
1695
  }
982
1696
  formatStartupError(message) {
@@ -1030,10 +1744,13 @@ class PlaylistPlayerSession {
1030
1744
  loop;
1031
1745
  deleteTrack;
1032
1746
  playlist;
1747
+ random;
1033
1748
  reloadPlaylist;
1034
1749
  revealTrack;
1035
1750
  searchQuery = "";
1036
1751
  selectedIndex = 0;
1752
+ shuffle;
1753
+ shuffleOrder = [];
1037
1754
  operationQueue = Promise.resolve();
1038
1755
  unsubscribeBackend;
1039
1756
  constructor(playlist, backend, options = {}) {
@@ -1041,8 +1758,13 @@ class PlaylistPlayerSession {
1041
1758
  this.playlist = playlist;
1042
1759
  this.deleteTrack = options.deleteTrack;
1043
1760
  this.loop = options.loop ?? false;
1761
+ this.random = options.random ?? Math.random;
1044
1762
  this.reloadPlaylist = options.reloadPlaylist;
1045
1763
  this.revealTrack = options.revealTrack;
1764
+ this.shuffle = options.shuffle ?? false;
1765
+ if (this.shuffle) {
1766
+ this.resetShuffleOrder(this.selectedIndex);
1767
+ }
1046
1768
  this.unsubscribeBackend = backend.subscribe((event) => {
1047
1769
  this.handleBackendEvent(event);
1048
1770
  });
@@ -1060,6 +1782,7 @@ class PlaylistPlayerSession {
1060
1782
  playlist: this.playlist,
1061
1783
  searchQuery: this.searchQuery,
1062
1784
  selectedIndex: this.selectedIndex,
1785
+ shuffle: this.shuffle,
1063
1786
  status: backendSnapshot.status,
1064
1787
  timePositionSeconds: backendSnapshot.timePositionSeconds
1065
1788
  };
@@ -1134,30 +1857,43 @@ class PlaylistPlayerSession {
1134
1857
  this.loop = !this.loop;
1135
1858
  this.emit();
1136
1859
  }
1860
+ toggleShuffle() {
1861
+ this.shuffle = !this.shuffle;
1862
+ if (this.shuffle) {
1863
+ this.resetShuffleOrder(this.currentIndex ?? this.selectedIndex);
1864
+ }
1865
+ this.emit();
1866
+ }
1137
1867
  appendSearchQuery(text) {
1138
- if (!text) {
1868
+ if (!text)
1139
1869
  return;
1140
- }
1141
1870
  this.searchQuery += text;
1142
1871
  this.syncSelectionToSearch();
1143
1872
  }
1144
1873
  deleteSearchCharacter() {
1145
- if (!this.searchQuery) {
1874
+ if (!this.searchQuery)
1146
1875
  return;
1147
- }
1148
1876
  this.searchQuery = this.searchQuery.slice(0, -1);
1149
1877
  this.syncSelectionToSearch();
1150
1878
  }
1879
+ deleteSearchWord() {
1880
+ if (!this.searchQuery)
1881
+ return;
1882
+ this.searchQuery = this.searchQuery.replace(/\s*\S+\s*$/, "");
1883
+ this.syncSelectionToSearch();
1884
+ }
1151
1885
  clearSearch() {
1152
- if (!this.searchQuery) {
1886
+ if (!this.searchQuery)
1153
1887
  return;
1154
- }
1155
1888
  this.searchQuery = "";
1156
1889
  this.emit();
1157
1890
  }
1158
1891
  async playSelected() {
1159
1892
  const selectedIndex = this.selectedIndex;
1160
1893
  await this.enqueueOperation(async () => {
1894
+ if (this.shuffle) {
1895
+ this.resetShuffleOrder(selectedIndex);
1896
+ }
1161
1897
  await this.playIndex(selectedIndex);
1162
1898
  });
1163
1899
  }
@@ -1274,6 +2010,9 @@ class PlaylistPlayerSession {
1274
2010
  const nextCurrentIndex = this.playlist.findIndex((playlistTrack) => playlistTrack.hash === currentTrack.hash);
1275
2011
  this.currentIndex = nextCurrentIndex === -1 ? null : nextCurrentIndex;
1276
2012
  }
2013
+ if (this.shuffle) {
2014
+ this.resetShuffleOrder(this.currentIndex ?? this.selectedIndex);
2015
+ }
1277
2016
  this.emit();
1278
2017
  } catch (error) {
1279
2018
  this.reportError(error);
@@ -1319,6 +2058,19 @@ class PlaylistPlayerSession {
1319
2058
  return null;
1320
2059
  }
1321
2060
  const baseIndex = this.currentIndex ?? this.selectedIndex;
2061
+ if (this.shuffle) {
2062
+ let orderIndex = this.shuffleOrder.indexOf(baseIndex);
2063
+ if (orderIndex === -1) {
2064
+ this.resetShuffleOrder(baseIndex);
2065
+ orderIndex = 0;
2066
+ }
2067
+ const nextOrderIndex = orderIndex + delta;
2068
+ const shouldWrap2 = wrap || this.loop;
2069
+ if (nextOrderIndex < 0 || nextOrderIndex >= this.shuffleOrder.length) {
2070
+ return shouldWrap2 ? this.shuffleOrder[wrapIndex(nextOrderIndex, this.shuffleOrder.length)] ?? null : null;
2071
+ }
2072
+ return this.shuffleOrder[nextOrderIndex] ?? null;
2073
+ }
1322
2074
  const nextIndex = baseIndex + delta;
1323
2075
  const shouldWrap = wrap || this.loop;
1324
2076
  if (nextIndex < 0) {
@@ -1329,6 +2081,17 @@ class PlaylistPlayerSession {
1329
2081
  }
1330
2082
  return nextIndex;
1331
2083
  }
2084
+ resetShuffleOrder(firstIndex) {
2085
+ const remainingIndices = this.playlist.map((_, index) => index).filter((index) => index !== firstIndex);
2086
+ for (let index = remainingIndices.length - 1;index > 0; index -= 1) {
2087
+ const swapIndex = Math.floor(this.random() * (index + 1));
2088
+ [remainingIndices[index], remainingIndices[swapIndex]] = [
2089
+ remainingIndices[swapIndex],
2090
+ remainingIndices[index]
2091
+ ];
2092
+ }
2093
+ this.shuffleOrder = this.playlist[firstIndex] ? [firstIndex, ...remainingIndices] : remainingIndices;
2094
+ }
1332
2095
  handleBackendEvent(event) {
1333
2096
  switch (event.type) {
1334
2097
  case "state":
@@ -1543,6 +2306,10 @@ class PlaylistPlayerScreen {
1543
2306
  this.session.toggleLoop();
1544
2307
  return;
1545
2308
  }
2309
+ if (import_pi_tui.matchesKey(data, "x")) {
2310
+ this.session.toggleShuffle();
2311
+ return;
2312
+ }
1546
2313
  if (import_pi_tui.matchesKey(data, "o")) {
1547
2314
  this.session.revealSelectedTrack();
1548
2315
  return;
@@ -1580,7 +2347,7 @@ class PlaylistPlayerScreen {
1580
2347
  } else if (this.searchMode || this.snapshot.searchQuery) {
1581
2348
  lines.push(import_pi_tui.truncateToWidth(style(`/ ${this.snapshot.searchQuery}${this.searchMode ? "▏" : ""}`, CYAN), width));
1582
2349
  } else {
1583
- lines.push(import_pi_tui.truncateToWidth(style(`${this.snapshot.selectedIndex + 1}/${Math.max(playlist.length, 1)} · loop ${this.snapshot.loop ? "on" : "off"} · ${this.snapshot.backendName}`, DIM), width));
2350
+ lines.push(import_pi_tui.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));
1584
2351
  }
1585
2352
  if (playlist.length === 0) {
1586
2353
  lines.push(import_pi_tui.truncateToWidth("No tracks were found in your osu!lazer library.", width));
@@ -1606,7 +2373,7 @@ class PlaylistPlayerScreen {
1606
2373
  while (lines.length < viewportHeight - 1) {
1607
2374
  lines.push("");
1608
2375
  }
1609
- lines.push(import_pi_tui.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));
2376
+ lines.push(import_pi_tui.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));
1610
2377
  return lines;
1611
2378
  }
1612
2379
  armPendingGoToTop() {
@@ -1659,6 +2426,10 @@ class PlaylistPlayerScreen {
1659
2426
  this.session.deleteSearchCharacter();
1660
2427
  return;
1661
2428
  }
2429
+ if (import_pi_tui.matchesKey(data, import_pi_tui.Key.ctrl("w"))) {
2430
+ this.session.deleteSearchWord();
2431
+ return;
2432
+ }
1662
2433
  if (import_pi_tui.matchesKey(data, import_pi_tui.Key.ctrl("u"))) {
1663
2434
  this.session.clearSearch();
1664
2435
  return;
@@ -1701,33 +2472,17 @@ class PlaylistPlayerScreen {
1701
2472
 
1702
2473
  // src/core/cli/main.ts
1703
2474
  init_mod();
1704
- function formatRealmLoadError(error) {
1705
- return [
1706
- "Realm native bindings could not be loaded.",
1707
- `Current Node runtime: ${process.version}.`,
1708
- "To repair Realm bindings in this project, run `bun run repair:realm` (or `bun run setup` to reinstall dependencies).",
1709
- "If you installed dependencies on Node 22, switch to Node 20 LTS and rerun the repair command.",
1710
- `Original error: ${error instanceof Error ? error.message : String(error)}`
1711
- ].join(`
1712
- `);
1713
- }
1714
- async function loadRealmDependencies() {
1715
- try {
1716
- const [{ default: Realm8 }, lazerModule] = await Promise.all([
1717
- import("realm"),
1718
- Promise.resolve().then(() => (init_mod3(), exports_mod2))
1719
- ]);
1720
- return {
1721
- Realm: Realm8,
1722
- ...lazerModule
1723
- };
1724
- } catch (error) {
1725
- throw new Error(formatRealmLoadError(error), { cause: error });
1726
- }
1727
- }
1728
2475
  function getArgs() {
1729
- return import_yargs.default(import_helpers.hideBin(process.argv)).scriptName("osu-play").usage(`Play music from your osu!lazer beatmaps in a minimal terminal player
1730
- Usage: $0 [options]`).option("reload", {
2476
+ return import_yargs.default(import_helpers.hideBin(process.argv)).scriptName("osu-play").usage(`Play or serve music from your osu!lazer beatmaps
2477
+ Usage: $0 [options]`).option("api", {
2478
+ type: "boolean",
2479
+ default: false,
2480
+ describe: "Run a localhost Subsonic API for music clients such as Kopuz"
2481
+ }).option("apiPort", {
2482
+ type: "number",
2483
+ default: 4533,
2484
+ describe: "Port for --api mode (binds to 127.0.0.1)"
2485
+ }).option("reload", {
1731
2486
  type: "boolean",
1732
2487
  default: false,
1733
2488
  alias: "r",
@@ -1749,44 +2504,38 @@ Usage: $0 [options]`).option("reload", {
1749
2504
  default: false,
1750
2505
  alias: "l",
1751
2506
  describe: "Loop the playlist when playback reaches the end"
2507
+ }).option("shuffle", {
2508
+ type: "boolean",
2509
+ default: false,
2510
+ alias: "s",
2511
+ describe: "Play the playlist in shuffled order"
2512
+ }).check((argv) => {
2513
+ if (!Number.isInteger(argv.apiPort) || argv.apiPort < 1 || argv.apiPort > 65535) {
2514
+ throw new Error("--apiPort must be an integer between 1 and 65535");
2515
+ }
2516
+ const conflictingMode = [
2517
+ argv.exportPlaylist ? "exportPlaylist" : null,
2518
+ argv.loop ? "loop" : null,
2519
+ argv.shuffle ? "shuffle" : null
2520
+ ].find(Boolean);
2521
+ if (argv.api && conflictingMode) {
2522
+ throw new Error(`--api cannot be combined with --${conflictingMode}`);
2523
+ }
2524
+ return true;
1752
2525
  }).alias("help", "h").help().parse();
1753
2526
  }
1754
- async function createPlaylist(osuDataDir) {
1755
- const realmDBPath = getRealmDBPath({ osuDataDir });
1756
- if (!realmDBPath) {
1757
- throw new Error("Realm DB not found");
1758
- }
1759
- const {
1760
- Realm: Realm8,
1761
- formatLazerSchemaCompatibilityError: formatLazerSchemaCompatibilityError2,
1762
- getBeatmapSets: getBeatmapSets2,
1763
- getLazerDB: getLazerDB2,
1764
- inspectLazerSchema: inspectLazerSchema2
1765
- } = await loadRealmDependencies();
1766
- Realm8.flags.ALLOW_CLEAR_TEST_STATE = true;
1767
- const realm = await getLazerDB2(realmDBPath);
1768
- try {
1769
- const schemaReport = inspectLazerSchema2(realm);
1770
- if (!schemaReport.compatible) {
1771
- throw new Error(formatLazerSchemaCompatibilityError2(schemaReport));
1772
- }
1773
- return buildPlaylist(getBeatmapSets2(realm), osuDataDir);
1774
- } finally {
1775
- realm.close();
1776
- }
1777
- }
1778
2527
  async function deleteTrackFromCollection(track, osuDataDir) {
1779
- const { deleteBeatmapSet: deleteBeatmapSet2 } = await loadRealmDependencies();
1780
- await deleteBeatmapSet2(track, osuDataDir);
2528
+ await deleteBeatmapSetFromLazer(track, osuDataDir);
1781
2529
  }
1782
- async function runTuiPlayer(playlist, osuDataDir, loop) {
2530
+ async function runTuiPlayer(playlist, osuDataDir, loop, shuffle) {
1783
2531
  const { revealFile: revealFile2 } = await Promise.resolve().then(() => (init_mod(), exports_mod));
1784
2532
  const backend = new MpvPlayerBackend;
1785
2533
  const session = new PlaylistPlayerSession(playlist, backend, {
1786
2534
  deleteTrack: (track) => deleteTrackFromCollection(track, osuDataDir),
1787
2535
  loop,
1788
- reloadPlaylist: () => createPlaylist(osuDataDir),
1789
- revealTrack: (track) => revealFile2(track.path)
2536
+ reloadPlaylist: () => loadPlaylistFromLazer(osuDataDir),
2537
+ revealTrack: (track) => revealFile2(track.path),
2538
+ shuffle
1790
2539
  });
1791
2540
  const terminal = new import_pi_tui2.ProcessTerminal;
1792
2541
  const tui = new import_pi_tui2.TUI(terminal);
@@ -1817,6 +2566,30 @@ async function runTuiPlayer(playlist, osuDataDir, loop) {
1817
2566
  await session.dispose();
1818
2567
  }
1819
2568
  }
2569
+ async function runApiMode(osuDataDir, port) {
2570
+ const catalogStore = await createLazerCatalogStore(osuDataDir);
2571
+ const server = await startApiServer({
2572
+ catalogStore,
2573
+ port
2574
+ });
2575
+ console.log(`[INFO] osu-play API listening at ${server.url}`);
2576
+ console.log("[INFO] In Kopuz, add a Custom server with this URL and any non-empty username/password.");
2577
+ console.log("[INFO] Press Ctrl+C to stop.");
2578
+ let resolveShutdown;
2579
+ const shutdown = new Promise((resolve) => {
2580
+ resolveShutdown = resolve;
2581
+ });
2582
+ const handleSignal = () => resolveShutdown?.();
2583
+ process.once("SIGINT", handleSignal);
2584
+ process.once("SIGTERM", handleSignal);
2585
+ try {
2586
+ await shutdown;
2587
+ } finally {
2588
+ process.off("SIGINT", handleSignal);
2589
+ process.off("SIGTERM", handleSignal);
2590
+ await server.close();
2591
+ }
2592
+ }
1820
2593
  async function main() {
1821
2594
  const argv = await getArgs();
1822
2595
  if (argv.reload) {
@@ -1825,16 +2598,20 @@ async function main() {
1825
2598
  if (argv.configDir) {
1826
2599
  console.log("[INFO] `--configDir` is deprecated and ignored because osu-play no longer copies the Realm DB.");
1827
2600
  }
1828
- const playlist = await createPlaylist(argv.osuDataDir);
2601
+ if (argv.api) {
2602
+ await runApiMode(argv.osuDataDir, argv.apiPort);
2603
+ return;
2604
+ }
2605
+ const playlist = await loadPlaylistFromLazer(argv.osuDataDir);
1829
2606
  if (argv.exportPlaylist) {
1830
2607
  const playlistContents = playlist.map((track) => track.path).join(`
1831
2608
  `);
1832
- import_node_fs3.writeFileSync(argv.exportPlaylist, playlistContents);
2609
+ import_node_fs4.writeFileSync(argv.exportPlaylist, playlistContents);
1833
2610
  console.log(`[INFO] Exported ${playlist.length} tracks to ${argv.exportPlaylist}.`);
1834
- console.log(`[INFO] Use something like \`mpv --playlist=${import_node_path3.default.resolve(argv.exportPlaylist)}\` to play the playlist.`);
2611
+ console.log(`[INFO] Use something like \`mpv --playlist=${import_node_path4.default.resolve(argv.exportPlaylist)}\` to play the playlist.`);
1835
2612
  return;
1836
2613
  }
1837
- await runTuiPlayer(playlist, argv.osuDataDir, argv.loop);
2614
+ await runTuiPlayer(playlist, argv.osuDataDir, argv.loop, argv.shuffle);
1838
2615
  }
1839
2616
 
1840
2617
  // src/cli.ts
@@ -1845,4 +2622,4 @@ main().then(() => {
1845
2622
  process.exit(1);
1846
2623
  });
1847
2624
 
1848
- //# debugId=9FB5B0E40D3B9A4364756E2164756E21
2625
+ //# debugId=B22693114D23F24364756E2164756E21