osu-play 1.3.2 → 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
@@ -29,8 +29,33 @@ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
29
29
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
30
30
 
31
31
  // src/core/utils/mod.ts
32
- import path from "node:path";
32
+ var exports_mod = {};
33
+ __export(exports_mod, {
34
+ revealFile: () => revealFile,
35
+ hashedFilePath: () => hashedFilePath,
36
+ getRealmDBPath: () => getRealmDBPath,
37
+ getDefaultOsuDataDir: () => getDefaultOsuDataDir,
38
+ getDefaultConfigPath: () => getDefaultConfigPath,
39
+ getDataDir: () => getDataDir,
40
+ getConfigDir: () => getConfigDir
41
+ });
42
+ import path2 from "node:path";
33
43
  import { existsSync } from "node:fs";
44
+ import { spawn } from "node:child_process";
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: [path2.dirname(filePath)], executable: "xdg-open" };
47
+ return new Promise((resolve, reject) => {
48
+ const child = spawn(command.executable, command.args, {
49
+ detached: true,
50
+ stdio: "ignore"
51
+ });
52
+ child.once("error", reject);
53
+ child.once("spawn", () => {
54
+ child.unref();
55
+ resolve();
56
+ });
57
+ });
58
+ }
34
59
  function getDataDir() {
35
60
  switch (process.platform) {
36
61
  case "linux":
@@ -41,13 +66,13 @@ function getDataDir() {
41
66
  return xdg;
42
67
  const home = process.env.HOME;
43
68
  if (home)
44
- return path.join(home, ".local", "share");
69
+ return path2.join(home, ".local", "share");
45
70
  break;
46
71
  }
47
72
  case "darwin": {
48
73
  const home = process.env.HOME;
49
74
  if (home)
50
- return path.join(home, "Library", "Application Support");
75
+ return path2.join(home, "Library", "Application Support");
51
76
  break;
52
77
  }
53
78
  case "win32":
@@ -65,13 +90,13 @@ function getConfigDir() {
65
90
  return xdg;
66
91
  const home = process.env.HOME;
67
92
  if (home)
68
- return path.join(home, ".config");
93
+ return path2.join(home, ".config");
69
94
  break;
70
95
  }
71
96
  case "darwin": {
72
97
  const home = process.env.HOME;
73
98
  if (home)
74
- return path.join(home, "Library", "Preferences");
99
+ return path2.join(home, "Library", "Preferences");
75
100
  break;
76
101
  }
77
102
  case "win32":
@@ -80,18 +105,18 @@ function getConfigDir() {
80
105
  return;
81
106
  }
82
107
  function getDefaultOsuDataDir() {
83
- return path.join(getDataDir() ?? ".", "osu");
108
+ return path2.join(getDataDir() ?? ".", "osu");
84
109
  }
85
110
  function getDefaultConfigPath(appName = "osu-play") {
86
- return path.join(getConfigDir() ?? ".", appName);
111
+ return path2.join(getConfigDir() ?? ".", appName);
87
112
  }
88
113
  function hashedFilePath(hash, osuDataDir = getDefaultOsuDataDir()) {
89
- 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);
90
115
  }
91
116
  function getRealmDBPath(legacyAppConfigDirOrOptions = {}, maybeOptions = {}) {
92
117
  const options = typeof legacyAppConfigDirOrOptions === "string" ? maybeOptions : legacyAppConfigDirOrOptions;
93
118
  const osuDataDir = options.osuDataDir ?? getDefaultOsuDataDir();
94
- const osuDBPath = path.join(osuDataDir, "client.realm");
119
+ const osuDBPath = path2.join(osuDataDir, "client.realm");
95
120
  if (!existsSync(osuDBPath)) {
96
121
  console.log(`[getRealmDBPath]: ${osuDBPath} not found`);
97
122
  return null;
@@ -400,8 +425,8 @@ var init_mod2 = __esm(() => {
400
425
  });
401
426
 
402
427
  // src/core/lazer/mod.ts
403
- var exports_mod = {};
404
- __export(exports_mod, {
428
+ var exports_mod2 = {};
429
+ __export(exports_mod2, {
405
430
  inspectLazerSchemaEntries: () => inspectLazerSchemaEntries,
406
431
  inspectLazerSchema: () => inspectLazerSchema,
407
432
  hashedFilePath: () => hashedFilePath,
@@ -489,12 +514,615 @@ var init_mod3 = __esm(() => {
489
514
  });
490
515
 
491
516
  // src/core/cli/main.ts
492
- import path3 from "node:path";
517
+ import path4 from "node:path";
493
518
  import { writeFileSync } from "node:fs";
494
519
  import { ProcessTerminal, TUI } from "@earendil-works/pi-tui";
495
520
  import yargs from "yargs/yargs";
496
521
  import { hideBin } from "yargs/helpers";
497
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
+
498
1126
  // src/core/playlist/mod.ts
499
1127
  init_mod();
500
1128
  function uniqueParts(parts) {
@@ -539,12 +1167,123 @@ function buildPlaylist(beatmapSets, osuDataDir) {
539
1167
  return playlist;
540
1168
  }
541
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
+
542
1281
  // src/core/player/mpv.ts
543
- import { spawn } from "node:child_process";
1282
+ import { spawn as spawn2 } from "node:child_process";
544
1283
  import { existsSync as existsSync2, rmSync } from "node:fs";
545
1284
  import net from "node:net";
546
1285
  import os from "node:os";
547
- import path2 from "node:path";
1286
+ import path3 from "node:path";
548
1287
  import { setTimeout as delay } from "node:timers/promises";
549
1288
  function getErrorCode(error) {
550
1289
  if (error && typeof error === "object" && "code" in error) {
@@ -566,7 +1305,7 @@ function createSocketPath() {
566
1305
  if (process.platform === "win32") {
567
1306
  return `\\\\.\\pipe\\${id}`;
568
1307
  }
569
- return path2.join(os.tmpdir(), `${id}.sock`);
1308
+ return path3.join(os.tmpdir(), `${id}.sock`);
570
1309
  }
571
1310
  function isControlText(value) {
572
1311
  return [...value].some((character) => {
@@ -695,7 +1434,7 @@ class MpvPlayerBackend {
695
1434
  this.cleanupSocketPath();
696
1435
  this.disposing = false;
697
1436
  this.stderrBuffer = "";
698
- const processHandle = spawn("mpv", [
1437
+ const processHandle = spawn2("mpv", [
699
1438
  "--no-config",
700
1439
  "--no-terminal",
701
1440
  "--idle=yes",
@@ -974,6 +1713,13 @@ function findTrackIndexByQuery(playlist, query) {
974
1713
  }
975
1714
  return playlist.findIndex((track) => normalizeSearchText(track.title).includes(normalizedQuery));
976
1715
  }
1716
+ function findTrackIndicesByQuery(playlist, query) {
1717
+ const normalizedQuery = normalizeSearchText(query);
1718
+ if (!normalizedQuery) {
1719
+ return playlist.map((_, index) => index);
1720
+ }
1721
+ return playlist.flatMap((track, index) => normalizeSearchText(track.title).includes(normalizedQuery) ? [index] : []);
1722
+ }
977
1723
 
978
1724
  class PlaylistPlayerSession {
979
1725
  backend;
@@ -983,9 +1729,13 @@ class PlaylistPlayerSession {
983
1729
  loop;
984
1730
  deleteTrack;
985
1731
  playlist;
1732
+ random;
986
1733
  reloadPlaylist;
1734
+ revealTrack;
987
1735
  searchQuery = "";
988
1736
  selectedIndex = 0;
1737
+ shuffle;
1738
+ shuffleOrder = [];
989
1739
  operationQueue = Promise.resolve();
990
1740
  unsubscribeBackend;
991
1741
  constructor(playlist, backend, options = {}) {
@@ -993,7 +1743,13 @@ class PlaylistPlayerSession {
993
1743
  this.playlist = playlist;
994
1744
  this.deleteTrack = options.deleteTrack;
995
1745
  this.loop = options.loop ?? false;
1746
+ this.random = options.random ?? Math.random;
996
1747
  this.reloadPlaylist = options.reloadPlaylist;
1748
+ this.revealTrack = options.revealTrack;
1749
+ this.shuffle = options.shuffle ?? false;
1750
+ if (this.shuffle) {
1751
+ this.resetShuffleOrder(this.selectedIndex);
1752
+ }
997
1753
  this.unsubscribeBackend = backend.subscribe((event) => {
998
1754
  this.handleBackendEvent(event);
999
1755
  });
@@ -1011,6 +1767,7 @@ class PlaylistPlayerSession {
1011
1767
  playlist: this.playlist,
1012
1768
  searchQuery: this.searchQuery,
1013
1769
  selectedIndex: this.selectedIndex,
1770
+ shuffle: this.shuffle,
1014
1771
  status: backendSnapshot.status,
1015
1772
  timePositionSeconds: backendSnapshot.timePositionSeconds
1016
1773
  };
@@ -1034,24 +1791,44 @@ class PlaylistPlayerSession {
1034
1791
  if (this.playlist.length === 0) {
1035
1792
  return;
1036
1793
  }
1794
+ if (this.searchQuery) {
1795
+ this.moveSearchSelection(delta);
1796
+ return;
1797
+ }
1037
1798
  this.selectedIndex = wrapIndex(this.selectedIndex + delta, this.playlist.length);
1038
1799
  this.emit();
1039
1800
  }
1040
1801
  moveSelectionPage(delta, pageSize) {
1802
+ if (this.searchQuery) {
1803
+ this.moveSearchSelection(delta * Math.max(1, pageSize));
1804
+ return;
1805
+ }
1041
1806
  this.moveSelection(delta * Math.max(1, pageSize));
1042
1807
  }
1808
+ moveSearchSelection(delta) {
1809
+ const matches = findTrackIndicesByQuery(this.playlist, this.searchQuery);
1810
+ if (matches.length === 0) {
1811
+ return;
1812
+ }
1813
+ const currentMatchIndex = matches.indexOf(this.selectedIndex);
1814
+ const nextMatchIndex = wrapIndex(currentMatchIndex + delta, matches.length);
1815
+ this.selectedIndex = matches[nextMatchIndex] ?? this.selectedIndex;
1816
+ this.emit();
1817
+ }
1043
1818
  selectHome() {
1044
1819
  if (this.playlist.length === 0) {
1045
1820
  return;
1046
1821
  }
1047
- this.selectedIndex = 0;
1822
+ const matches = findTrackIndicesByQuery(this.playlist, this.searchQuery);
1823
+ this.selectedIndex = matches[0] ?? 0;
1048
1824
  this.emit();
1049
1825
  }
1050
1826
  selectEnd() {
1051
1827
  if (this.playlist.length === 0) {
1052
1828
  return;
1053
1829
  }
1054
- this.selectedIndex = this.playlist.length - 1;
1830
+ const matches = findTrackIndicesByQuery(this.playlist, this.searchQuery);
1831
+ this.selectedIndex = matches.at(-1) ?? this.playlist.length - 1;
1055
1832
  this.emit();
1056
1833
  }
1057
1834
  setSelectionIndex(index) {
@@ -1065,30 +1842,43 @@ class PlaylistPlayerSession {
1065
1842
  this.loop = !this.loop;
1066
1843
  this.emit();
1067
1844
  }
1845
+ toggleShuffle() {
1846
+ this.shuffle = !this.shuffle;
1847
+ if (this.shuffle) {
1848
+ this.resetShuffleOrder(this.currentIndex ?? this.selectedIndex);
1849
+ }
1850
+ this.emit();
1851
+ }
1068
1852
  appendSearchQuery(text) {
1069
- if (!text) {
1853
+ if (!text)
1070
1854
  return;
1071
- }
1072
1855
  this.searchQuery += text;
1073
1856
  this.syncSelectionToSearch();
1074
1857
  }
1075
1858
  deleteSearchCharacter() {
1076
- if (!this.searchQuery) {
1859
+ if (!this.searchQuery)
1077
1860
  return;
1078
- }
1079
1861
  this.searchQuery = this.searchQuery.slice(0, -1);
1080
1862
  this.syncSelectionToSearch();
1081
1863
  }
1864
+ deleteSearchWord() {
1865
+ if (!this.searchQuery)
1866
+ return;
1867
+ this.searchQuery = this.searchQuery.replace(/\s*\S+\s*$/, "");
1868
+ this.syncSelectionToSearch();
1869
+ }
1082
1870
  clearSearch() {
1083
- if (!this.searchQuery) {
1871
+ if (!this.searchQuery)
1084
1872
  return;
1085
- }
1086
1873
  this.searchQuery = "";
1087
1874
  this.emit();
1088
1875
  }
1089
1876
  async playSelected() {
1090
1877
  const selectedIndex = this.selectedIndex;
1091
1878
  await this.enqueueOperation(async () => {
1879
+ if (this.shuffle) {
1880
+ this.resetShuffleOrder(selectedIndex);
1881
+ }
1092
1882
  await this.playIndex(selectedIndex);
1093
1883
  });
1094
1884
  }
@@ -1157,6 +1947,22 @@ class PlaylistPlayerSession {
1157
1947
  }
1158
1948
  });
1159
1949
  }
1950
+ async revealSelectedTrack() {
1951
+ const track = this.playlist[this.selectedIndex];
1952
+ if (!track) {
1953
+ return;
1954
+ }
1955
+ if (!this.revealTrack) {
1956
+ this.reportError(new Error("Opening the containing folder is unavailable."));
1957
+ return;
1958
+ }
1959
+ try {
1960
+ this.clearError();
1961
+ await this.revealTrack(track);
1962
+ } catch (error) {
1963
+ this.reportError(error);
1964
+ }
1965
+ }
1160
1966
  async deleteSelectedTrack() {
1161
1967
  const selectedIndex = this.selectedIndex;
1162
1968
  await this.enqueueOperation(async () => {
@@ -1189,6 +1995,9 @@ class PlaylistPlayerSession {
1189
1995
  const nextCurrentIndex = this.playlist.findIndex((playlistTrack) => playlistTrack.hash === currentTrack.hash);
1190
1996
  this.currentIndex = nextCurrentIndex === -1 ? null : nextCurrentIndex;
1191
1997
  }
1998
+ if (this.shuffle) {
1999
+ this.resetShuffleOrder(this.currentIndex ?? this.selectedIndex);
2000
+ }
1192
2001
  this.emit();
1193
2002
  } catch (error) {
1194
2003
  this.reportError(error);
@@ -1234,6 +2043,19 @@ class PlaylistPlayerSession {
1234
2043
  return null;
1235
2044
  }
1236
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
+ }
1237
2059
  const nextIndex = baseIndex + delta;
1238
2060
  const shouldWrap = wrap || this.loop;
1239
2061
  if (nextIndex < 0) {
@@ -1244,6 +2066,17 @@ class PlaylistPlayerSession {
1244
2066
  }
1245
2067
  return nextIndex;
1246
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
+ }
1247
2080
  handleBackendEvent(event) {
1248
2081
  switch (event.type) {
1249
2082
  case "state":
@@ -1463,6 +2296,14 @@ class PlaylistPlayerScreen {
1463
2296
  this.session.toggleLoop();
1464
2297
  return;
1465
2298
  }
2299
+ if (matchesKey(data, "x")) {
2300
+ this.session.toggleShuffle();
2301
+ return;
2302
+ }
2303
+ if (matchesKey(data, "o")) {
2304
+ this.session.revealSelectedTrack();
2305
+ return;
2306
+ }
1466
2307
  if (matchesKey(data, Key.backspace)) {
1467
2308
  this.session.deleteSearchCharacter();
1468
2309
  return;
@@ -1474,9 +2315,11 @@ class PlaylistPlayerScreen {
1474
2315
  }
1475
2316
  render(width) {
1476
2317
  const { playlist } = this.snapshot;
2318
+ const visibleIndices = this.snapshot.searchQuery ? findTrackIndicesByQuery(playlist, this.snapshot.searchQuery) : playlist.map((_, index) => index);
2319
+ const selectedVisibleIndex = Math.max(0, visibleIndices.indexOf(this.snapshot.selectedIndex));
1477
2320
  const viewportHeight = Math.max(this.getViewportHeight(), RESERVED_ROWS);
1478
2321
  const listHeight = this.getListHeight(viewportHeight);
1479
- const { start, end } = getVisibleTrackRange(this.snapshot.selectedIndex, playlist.length, listHeight);
2322
+ const { start, end } = getVisibleTrackRange(selectedVisibleIndex, visibleIndices.length, listHeight);
1480
2323
  const position = formatSeconds(this.snapshot.timePositionSeconds);
1481
2324
  const duration = formatSeconds(this.snapshot.durationSeconds);
1482
2325
  const glyph = STATUS_GLYPH[this.snapshot.status] ?? "·";
@@ -1494,12 +2337,18 @@ class PlaylistPlayerScreen {
1494
2337
  } else if (this.searchMode || this.snapshot.searchQuery) {
1495
2338
  lines.push(truncateToWidth(style(`/ ${this.snapshot.searchQuery}${this.searchMode ? "▏" : ""}`, CYAN), width));
1496
2339
  } else {
1497
- 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));
1498
2341
  }
1499
2342
  if (playlist.length === 0) {
1500
2343
  lines.push(truncateToWidth("No tracks were found in your osu!lazer library.", width));
2344
+ } else if (visibleIndices.length === 0) {
2345
+ lines.push(truncateToWidth("No tracks match the current search.", width));
1501
2346
  } else {
1502
- for (let index = start;index < end; index += 1) {
2347
+ for (let visibleIndex = start;visibleIndex < end; visibleIndex += 1) {
2348
+ const index = visibleIndices[visibleIndex];
2349
+ if (index === undefined) {
2350
+ continue;
2351
+ }
1503
2352
  const track = playlist[index];
1504
2353
  if (!track) {
1505
2354
  continue;
@@ -1514,7 +2363,7 @@ class PlaylistPlayerScreen {
1514
2363
  while (lines.length < viewportHeight - 1) {
1515
2364
  lines.push("");
1516
2365
  }
1517
- lines.push(truncateToWidth(style(this.searchMode ? "type to jump · backspace edit · enter keep · esc leave" : "j/k move · ⏎ play · space pause · n/p track · h/l seek · 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));
1518
2367
  return lines;
1519
2368
  }
1520
2369
  armPendingGoToTop() {
@@ -1556,6 +2405,7 @@ class PlaylistPlayerScreen {
1556
2405
  }
1557
2406
  if (matchesKey(data, Key.enter)) {
1558
2407
  this.searchMode = false;
2408
+ this.session.playSelected();
1559
2409
  return;
1560
2410
  }
1561
2411
  if (matchesKey(data, Key.escape)) {
@@ -1566,10 +2416,22 @@ class PlaylistPlayerScreen {
1566
2416
  this.session.deleteSearchCharacter();
1567
2417
  return;
1568
2418
  }
2419
+ if (matchesKey(data, Key.ctrl("w"))) {
2420
+ this.session.deleteSearchWord();
2421
+ return;
2422
+ }
1569
2423
  if (matchesKey(data, Key.ctrl("u"))) {
1570
2424
  this.session.clearSearch();
1571
2425
  return;
1572
2426
  }
2427
+ if (matchesKey(data, Key.up) || matchesKey(data, Key.ctrl("p"))) {
2428
+ this.session.moveSearchSelection(-1);
2429
+ return;
2430
+ }
2431
+ if (matchesKey(data, Key.down) || matchesKey(data, Key.ctrl("n"))) {
2432
+ this.session.moveSearchSelection(1);
2433
+ return;
2434
+ }
1573
2435
  const printable = decodePrintableText(data);
1574
2436
  if (!printable) {
1575
2437
  return;
@@ -1600,33 +2462,17 @@ class PlaylistPlayerScreen {
1600
2462
 
1601
2463
  // src/core/cli/main.ts
1602
2464
  init_mod();
1603
- function formatRealmLoadError(error) {
1604
- return [
1605
- "Realm native bindings could not be loaded.",
1606
- `Current Node runtime: ${process.version}.`,
1607
- "To repair Realm bindings in this project, run `bun run repair:realm` (or `bun run setup` to reinstall dependencies).",
1608
- "If you installed dependencies on Node 22, switch to Node 20 LTS and rerun the repair command.",
1609
- `Original error: ${error instanceof Error ? error.message : String(error)}`
1610
- ].join(`
1611
- `);
1612
- }
1613
- async function loadRealmDependencies() {
1614
- try {
1615
- const [{ default: Realm8 }, lazerModule] = await Promise.all([
1616
- import("realm"),
1617
- Promise.resolve().then(() => (init_mod3(), exports_mod))
1618
- ]);
1619
- return {
1620
- Realm: Realm8,
1621
- ...lazerModule
1622
- };
1623
- } catch (error) {
1624
- throw new Error(formatRealmLoadError(error), { cause: error });
1625
- }
1626
- }
1627
2465
  function getArgs() {
1628
- return yargs(hideBin(process.argv)).scriptName("osu-play").usage(`Play music from your osu!lazer beatmaps in a minimal terminal player
1629
- 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", {
1630
2476
  type: "boolean",
1631
2477
  default: false,
1632
2478
  alias: "r",
@@ -1648,42 +2494,38 @@ Usage: $0 [options]`).option("reload", {
1648
2494
  default: false,
1649
2495
  alias: "l",
1650
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;
1651
2515
  }).alias("help", "h").help().parse();
1652
2516
  }
1653
- async function createPlaylist(osuDataDir) {
1654
- const realmDBPath = getRealmDBPath({ osuDataDir });
1655
- if (!realmDBPath) {
1656
- throw new Error("Realm DB not found");
1657
- }
1658
- const {
1659
- Realm: Realm8,
1660
- formatLazerSchemaCompatibilityError: formatLazerSchemaCompatibilityError2,
1661
- getBeatmapSets: getBeatmapSets2,
1662
- getLazerDB: getLazerDB2,
1663
- inspectLazerSchema: inspectLazerSchema2
1664
- } = await loadRealmDependencies();
1665
- Realm8.flags.ALLOW_CLEAR_TEST_STATE = true;
1666
- const realm = await getLazerDB2(realmDBPath);
1667
- try {
1668
- const schemaReport = inspectLazerSchema2(realm);
1669
- if (!schemaReport.compatible) {
1670
- throw new Error(formatLazerSchemaCompatibilityError2(schemaReport));
1671
- }
1672
- return buildPlaylist(getBeatmapSets2(realm), osuDataDir);
1673
- } finally {
1674
- realm.close();
1675
- }
1676
- }
1677
2517
  async function deleteTrackFromCollection(track, osuDataDir) {
1678
- const { deleteBeatmapSet: deleteBeatmapSet2 } = await loadRealmDependencies();
1679
- await deleteBeatmapSet2(track, osuDataDir);
2518
+ await deleteBeatmapSetFromLazer(track, osuDataDir);
1680
2519
  }
1681
- async function runTuiPlayer(playlist, osuDataDir, loop) {
2520
+ async function runTuiPlayer(playlist, osuDataDir, loop, shuffle) {
2521
+ const { revealFile: revealFile2 } = await Promise.resolve().then(() => (init_mod(), exports_mod));
1682
2522
  const backend = new MpvPlayerBackend;
1683
2523
  const session = new PlaylistPlayerSession(playlist, backend, {
1684
2524
  deleteTrack: (track) => deleteTrackFromCollection(track, osuDataDir),
1685
2525
  loop,
1686
- reloadPlaylist: () => createPlaylist(osuDataDir)
2526
+ reloadPlaylist: () => loadPlaylistFromLazer(osuDataDir),
2527
+ revealTrack: (track) => revealFile2(track.path),
2528
+ shuffle
1687
2529
  });
1688
2530
  const terminal = new ProcessTerminal;
1689
2531
  const tui = new TUI(terminal);
@@ -1714,6 +2556,30 @@ async function runTuiPlayer(playlist, osuDataDir, loop) {
1714
2556
  await session.dispose();
1715
2557
  }
1716
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
+ }
1717
2583
  async function main() {
1718
2584
  const argv = await getArgs();
1719
2585
  if (argv.reload) {
@@ -1722,16 +2588,20 @@ async function main() {
1722
2588
  if (argv.configDir) {
1723
2589
  console.log("[INFO] `--configDir` is deprecated and ignored because osu-play no longer copies the Realm DB.");
1724
2590
  }
1725
- 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);
1726
2596
  if (argv.exportPlaylist) {
1727
2597
  const playlistContents = playlist.map((track) => track.path).join(`
1728
2598
  `);
1729
2599
  writeFileSync(argv.exportPlaylist, playlistContents);
1730
2600
  console.log(`[INFO] Exported ${playlist.length} tracks to ${argv.exportPlaylist}.`);
1731
- 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.`);
1732
2602
  return;
1733
2603
  }
1734
- await runTuiPlayer(playlist, argv.osuDataDir, argv.loop);
2604
+ await runTuiPlayer(playlist, argv.osuDataDir, argv.loop, argv.shuffle);
1735
2605
  }
1736
2606
 
1737
2607
  // src/cli.ts
@@ -1742,4 +2612,4 @@ main().then(() => {
1742
2612
  process.exit(1);
1743
2613
  });
1744
2614
 
1745
- //# debugId=434A8694113114A064756E2164756E21
2615
+ //# debugId=7C34DADA116CE55564756E2164756E21