version 1.0.0
This commit is contained in:
commit
4b8701e55b
118
.gitignore
vendored
Normal file
118
.gitignore
vendored
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
### macOS
|
||||||
|
# Finder metadata
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Thumbnails
|
||||||
|
._*
|
||||||
|
|
||||||
|
# Custom folder icons
|
||||||
|
Icon
|
||||||
|
|
||||||
|
# Volume root files
|
||||||
|
.DocumentRevisions-V100
|
||||||
|
.fseventsd
|
||||||
|
.Spotlight-V100
|
||||||
|
.TemporaryItems
|
||||||
|
.Trashes
|
||||||
|
.VolumeIcon.icns
|
||||||
|
.com.apple.timemachine.donotpresent
|
||||||
|
|
||||||
|
### VS Code
|
||||||
|
# VSCode settings (keep shared configuration)
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
|
||||||
|
# Local History for Visual Studio Code
|
||||||
|
.history/
|
||||||
|
|
||||||
|
# Built Visual Studio Code Extensions
|
||||||
|
*.vsix
|
||||||
|
|
||||||
|
### Node
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
*.pid
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Coverage
|
||||||
|
coverage/
|
||||||
|
*.lcov
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Framework build output and caches
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
.next
|
||||||
|
out/
|
||||||
|
.nuxt
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# npm cache directory
|
||||||
|
.npm
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# yarn v2
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.pnp.*
|
||||||
|
|
||||||
|
### Objective-C
|
||||||
|
# Xcode user settings
|
||||||
|
xcuserdata/
|
||||||
|
|
||||||
|
# Xcode build data
|
||||||
|
DerivedData/
|
||||||
|
|
||||||
|
# Obj-C/Swift specific
|
||||||
|
*.hmap
|
||||||
|
|
||||||
|
# App packaging
|
||||||
|
*.ipa
|
||||||
|
*.dSYM.zip
|
||||||
|
*.dSYM
|
||||||
|
|
||||||
|
# Playgrounds
|
||||||
|
timeline.xctimeline
|
||||||
|
playground.xcworkspace
|
||||||
|
|
||||||
|
### Swift
|
||||||
|
# Xcode user settings
|
||||||
|
xcuserdata/
|
||||||
|
|
||||||
|
# Xcode build data
|
||||||
|
DerivedData/
|
||||||
|
|
||||||
|
# Swift/Obj-C specific
|
||||||
|
*.hmap
|
||||||
|
|
||||||
|
# App packaging
|
||||||
|
*.ipa
|
||||||
|
*.dSYM.zip
|
||||||
|
*.dSYM
|
||||||
|
|
||||||
|
# Playgrounds
|
||||||
|
timeline.xctimeline
|
||||||
|
playground.xcworkspace
|
||||||
|
|
||||||
|
# Swift Package Manager
|
||||||
|
# Package.resolved
|
||||||
56
README.md
Normal file
56
README.md
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# Velody
|
||||||
|
|
||||||
|
Velody is a private music ecosystem for personal use across `macOS`, `iPhone`, and a VPS backend.
|
||||||
|
|
||||||
|
This repository currently contains the execution foundation for Phase 1:
|
||||||
|
|
||||||
|
- `apps/apple` for the native Apple apps workspace
|
||||||
|
- `packages/apple` for shared Swift packages
|
||||||
|
- `backend` for the NestJS API and Prisma schema
|
||||||
|
- `infra` for local Docker and deployment references
|
||||||
|
- `runtime` for local development storage paths
|
||||||
|
- `docs/PROJECT_ENVIRONMENT_ARCHITECTURE.md` as the functional source of truth
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Xcode 26+
|
||||||
|
- Swift 5.9+
|
||||||
|
- Node.js 20+
|
||||||
|
- npm 10+
|
||||||
|
- Docker Desktop
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
1. Copy the example environment file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start Postgres and the API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f infra/docker/compose.local.yml up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Open the Apple workspace:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
open apps/apple/Velody.xcworkspace
|
||||||
|
```
|
||||||
|
|
||||||
|
## Current Scope
|
||||||
|
|
||||||
|
This phase intentionally provides:
|
||||||
|
|
||||||
|
- backend contracts and scaffolding
|
||||||
|
- database schema and initial migration
|
||||||
|
- local filesystem storage readiness checks
|
||||||
|
- placeholder Apple apps and shared packages
|
||||||
|
|
||||||
|
This phase intentionally does not yet implement:
|
||||||
|
|
||||||
|
- folder watching
|
||||||
|
- upload chunk transfer
|
||||||
|
- background downloads
|
||||||
|
- audio playback
|
||||||
522
apps/apple/Velody.xcodeproj/project.pbxproj
Normal file
522
apps/apple/Velody.xcodeproj/project.pbxproj
Normal file
@ -0,0 +1,522 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 77;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
0AF7078E8BD078ECE18B6C0A /* VelodyiPhoneApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2AEECC422FC77F78C45B17D /* VelodyiPhoneApp.swift */; };
|
||||||
|
151772779307EFC3B3A17477 /* MacLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD72F644E3F2E28758E68D63 /* MacLibraryViewModel.swift */; };
|
||||||
|
2E9DD262BF65832378F37DD4 /* VelodyPersistence in Frameworks */ = {isa = PBXBuildFile; productRef = 4DD09D7C123E184CEC0A2F4D /* VelodyPersistence */; };
|
||||||
|
2F9E426A66F4887C301AB13C /* FolderAccessService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EC9AED651FE0AB193AAFE94 /* FolderAccessService.swift */; };
|
||||||
|
3D22DE55C4A27A4DE68E6359 /* VelodyUtilities in Frameworks */ = {isa = PBXBuildFile; productRef = AAD3F903A475AA0B0159C79E /* VelodyUtilities */; };
|
||||||
|
3D2A2E7D9371C62F8F86DD84 /* VelodyDomain in Frameworks */ = {isa = PBXBuildFile; productRef = 3C910108376E6ECEF152DCE1 /* VelodyDomain */; };
|
||||||
|
5D2616BFA3DD5E131EC928F2 /* iPhoneLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE56EE8AD38DC563003A7979 /* iPhoneLibraryViewModel.swift */; };
|
||||||
|
7174D80FB45839E82F150613 /* VelodyMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96A45EA6FB9536D0CD91874C /* VelodyMacApp.swift */; };
|
||||||
|
783EBF82108F7A04B6DD33B4 /* VelodySync in Frameworks */ = {isa = PBXBuildFile; productRef = DA5EBADF45BC0977F73F241C /* VelodySync */; };
|
||||||
|
8A3C2C7EB14F364D682A92B7 /* VelodyNetworking in Frameworks */ = {isa = PBXBuildFile; productRef = 713279722B202FB1CF4A869E /* VelodyNetworking */; };
|
||||||
|
93F386D6A8B0131EAB50E2B9 /* VelodyDomain in Frameworks */ = {isa = PBXBuildFile; productRef = 48BF8F8596E7A86383A9CCD1 /* VelodyDomain */; };
|
||||||
|
A54D8AD8A59D8B77FCA0794F /* MacLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB28FE17346E100F697C1BF4 /* MacLibraryView.swift */; };
|
||||||
|
AB6C7E42A3A850D395E4F5E7 /* VelodySync in Frameworks */ = {isa = PBXBuildFile; productRef = 2449C403E81DD84D7A8DD7E1 /* VelodySync */; };
|
||||||
|
D0D65CE73B9DFF3C73F432DB /* VelodyUtilities in Frameworks */ = {isa = PBXBuildFile; productRef = B15F842ACBB110CC8A766669 /* VelodyUtilities */; };
|
||||||
|
D4B554447B262C7B946ED21F /* VelodyPersistence in Frameworks */ = {isa = PBXBuildFile; productRef = C8F5FF593C4DB829D1CDD497 /* VelodyPersistence */; };
|
||||||
|
EE48EF0688C7E33CDA783234 /* VelodyNetworking in Frameworks */ = {isa = PBXBuildFile; productRef = 0682A261A6F2F050F4B83AF6 /* VelodyNetworking */; };
|
||||||
|
FB76843BB27CCCD2B0CFF11D /* iPhoneLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8848AEA828FE5B2607EB46 /* iPhoneLibraryView.swift */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
07508485E10C6E2942FE29AB /* VelodySync */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodySync; path = ../../packages/apple/VelodySync; sourceTree = SOURCE_ROOT; };
|
||||||
|
0F6993844F6FD7E86D52EC25 /* VelodyNetworking */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodyNetworking; path = ../../packages/apple/VelodyNetworking; sourceTree = SOURCE_ROOT; };
|
||||||
|
5EC9AED651FE0AB193AAFE94 /* FolderAccessService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderAccessService.swift; sourceTree = "<group>"; };
|
||||||
|
6E7BCB85A35B8286E4472822 /* VelodyPersistence */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodyPersistence; path = ../../packages/apple/VelodyPersistence; sourceTree = SOURCE_ROOT; };
|
||||||
|
7A1BDB40E1A38A685237BCF3 /* VelodyMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VelodyMac.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
89FE56E825FC42D026EAC784 /* VelodyUtilities */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodyUtilities; path = ../../packages/apple/VelodyUtilities; sourceTree = SOURCE_ROOT; };
|
||||||
|
96A45EA6FB9536D0CD91874C /* VelodyMacApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VelodyMacApp.swift; sourceTree = "<group>"; };
|
||||||
|
AD72F644E3F2E28758E68D63 /* MacLibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacLibraryViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
AE56EE8AD38DC563003A7979 /* iPhoneLibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iPhoneLibraryViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
BF223F7E40D4B594935F5BC2 /* VelodyiPhone.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = VelodyiPhone.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
DB28FE17346E100F697C1BF4 /* MacLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacLibraryView.swift; sourceTree = "<group>"; };
|
||||||
|
DD8848AEA828FE5B2607EB46 /* iPhoneLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iPhoneLibraryView.swift; sourceTree = "<group>"; };
|
||||||
|
F2AEECC422FC77F78C45B17D /* VelodyiPhoneApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VelodyiPhoneApp.swift; sourceTree = "<group>"; };
|
||||||
|
F7EF830CE1ABB841E624A660 /* VelodyDomain */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodyDomain; path = ../../packages/apple/VelodyDomain; sourceTree = SOURCE_ROOT; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
164EF3CE80CCC648290BE574 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
3D2A2E7D9371C62F8F86DD84 /* VelodyDomain in Frameworks */,
|
||||||
|
EE48EF0688C7E33CDA783234 /* VelodyNetworking in Frameworks */,
|
||||||
|
2E9DD262BF65832378F37DD4 /* VelodyPersistence in Frameworks */,
|
||||||
|
783EBF82108F7A04B6DD33B4 /* VelodySync in Frameworks */,
|
||||||
|
3D22DE55C4A27A4DE68E6359 /* VelodyUtilities in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
5EEE2B9B800ED62CFD5E16D5 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
93F386D6A8B0131EAB50E2B9 /* VelodyDomain in Frameworks */,
|
||||||
|
8A3C2C7EB14F364D682A92B7 /* VelodyNetworking in Frameworks */,
|
||||||
|
D4B554447B262C7B946ED21F /* VelodyPersistence in Frameworks */,
|
||||||
|
AB6C7E42A3A850D395E4F5E7 /* VelodySync in Frameworks */,
|
||||||
|
D0D65CE73B9DFF3C73F432DB /* VelodyUtilities in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
72E9140E6A9783B11030B506 /* Sources */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
DD8848AEA828FE5B2607EB46 /* iPhoneLibraryView.swift */,
|
||||||
|
AE56EE8AD38DC563003A7979 /* iPhoneLibraryViewModel.swift */,
|
||||||
|
F2AEECC422FC77F78C45B17D /* VelodyiPhoneApp.swift */,
|
||||||
|
);
|
||||||
|
name = Sources;
|
||||||
|
path = VelodyiPhone/Sources;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
770229FFB4009FE9ED8F7FA1 /* Packages */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
F7EF830CE1ABB841E624A660 /* VelodyDomain */,
|
||||||
|
0F6993844F6FD7E86D52EC25 /* VelodyNetworking */,
|
||||||
|
6E7BCB85A35B8286E4472822 /* VelodyPersistence */,
|
||||||
|
07508485E10C6E2942FE29AB /* VelodySync */,
|
||||||
|
89FE56E825FC42D026EAC784 /* VelodyUtilities */,
|
||||||
|
);
|
||||||
|
name = Packages;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
AB4CAC024DD51FEC5AD2CDB9 = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
770229FFB4009FE9ED8F7FA1 /* Packages */,
|
||||||
|
72E9140E6A9783B11030B506 /* Sources */,
|
||||||
|
C055BC0585BDA296E6A6D9F8 /* Sources */,
|
||||||
|
E7EA6D658DCAE7B098AACC54 /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C055BC0585BDA296E6A6D9F8 /* Sources */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
5EC9AED651FE0AB193AAFE94 /* FolderAccessService.swift */,
|
||||||
|
DB28FE17346E100F697C1BF4 /* MacLibraryView.swift */,
|
||||||
|
AD72F644E3F2E28758E68D63 /* MacLibraryViewModel.swift */,
|
||||||
|
96A45EA6FB9536D0CD91874C /* VelodyMacApp.swift */,
|
||||||
|
);
|
||||||
|
name = Sources;
|
||||||
|
path = VelodyMac/Sources;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
E7EA6D658DCAE7B098AACC54 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
BF223F7E40D4B594935F5BC2 /* VelodyiPhone.app */,
|
||||||
|
7A1BDB40E1A38A685237BCF3 /* VelodyMac.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
35991DE50CE00DB09C257624 /* VelodyMac */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = C3E3979BBD4515632B20BF81 /* Build configuration list for PBXNativeTarget "VelodyMac" */;
|
||||||
|
buildPhases = (
|
||||||
|
82777CB01A8F49D7D29F03D0 /* Sources */,
|
||||||
|
164EF3CE80CCC648290BE574 /* Frameworks */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = VelodyMac;
|
||||||
|
packageProductDependencies = (
|
||||||
|
3C910108376E6ECEF152DCE1 /* VelodyDomain */,
|
||||||
|
0682A261A6F2F050F4B83AF6 /* VelodyNetworking */,
|
||||||
|
4DD09D7C123E184CEC0A2F4D /* VelodyPersistence */,
|
||||||
|
DA5EBADF45BC0977F73F241C /* VelodySync */,
|
||||||
|
AAD3F903A475AA0B0159C79E /* VelodyUtilities */,
|
||||||
|
);
|
||||||
|
productName = VelodyMac;
|
||||||
|
productReference = 7A1BDB40E1A38A685237BCF3 /* VelodyMac.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
BA256DA9698C16C35E28D0EF /* VelodyiPhone */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 6BBCD1EA2ADBFF580325615C /* Build configuration list for PBXNativeTarget "VelodyiPhone" */;
|
||||||
|
buildPhases = (
|
||||||
|
05A8FC6F228F991C2ABD5A81 /* Sources */,
|
||||||
|
5EEE2B9B800ED62CFD5E16D5 /* Frameworks */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = VelodyiPhone;
|
||||||
|
packageProductDependencies = (
|
||||||
|
48BF8F8596E7A86383A9CCD1 /* VelodyDomain */,
|
||||||
|
713279722B202FB1CF4A869E /* VelodyNetworking */,
|
||||||
|
C8F5FF593C4DB829D1CDD497 /* VelodyPersistence */,
|
||||||
|
2449C403E81DD84D7A8DD7E1 /* VelodySync */,
|
||||||
|
B15F842ACBB110CC8A766669 /* VelodyUtilities */,
|
||||||
|
);
|
||||||
|
productName = VelodyiPhone;
|
||||||
|
productReference = BF223F7E40D4B594935F5BC2 /* VelodyiPhone.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
D14070EEF27F35DD4F5F547B /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = YES;
|
||||||
|
LastUpgradeCheck = 1430;
|
||||||
|
};
|
||||||
|
buildConfigurationList = A0556D22F619DDC41AA3C723 /* Build configuration list for PBXProject "Velody" */;
|
||||||
|
compatibilityVersion = "Xcode 14.0";
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
Base,
|
||||||
|
en,
|
||||||
|
);
|
||||||
|
mainGroup = AB4CAC024DD51FEC5AD2CDB9;
|
||||||
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
packageReferences = (
|
||||||
|
43AF8C7D2C6F46AB2748AAD9 /* XCLocalSwiftPackageReference "../../packages/apple/VelodyDomain" */,
|
||||||
|
5DC2C05FD0EC7BD980C0BF69 /* XCLocalSwiftPackageReference "../../packages/apple/VelodyNetworking" */,
|
||||||
|
E9AE6C97FDD54CE81E584DDC /* XCLocalSwiftPackageReference "../../packages/apple/VelodyPersistence" */,
|
||||||
|
60A72563C2E87FC2BA78729A /* XCLocalSwiftPackageReference "../../packages/apple/VelodySync" */,
|
||||||
|
4290F294795FE24C6BF03B5B /* XCLocalSwiftPackageReference "../../packages/apple/VelodyUtilities" */,
|
||||||
|
);
|
||||||
|
preferredProjectObjectVersion = 77;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
35991DE50CE00DB09C257624 /* VelodyMac */,
|
||||||
|
BA256DA9698C16C35E28D0EF /* VelodyiPhone */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
05A8FC6F228F991C2ABD5A81 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
0AF7078E8BD078ECE18B6C0A /* VelodyiPhoneApp.swift in Sources */,
|
||||||
|
FB76843BB27CCCD2B0CFF11D /* iPhoneLibraryView.swift in Sources */,
|
||||||
|
5D2616BFA3DD5E131EC928F2 /* iPhoneLibraryViewModel.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
82777CB01A8F49D7D29F03D0 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
2F9E426A66F4887C301AB13C /* FolderAccessService.swift in Sources */,
|
||||||
|
A54D8AD8A59D8B77FCA0794F /* MacLibraryView.swift in Sources */,
|
||||||
|
151772779307EFC3B3A17477 /* MacLibraryViewModel.swift in Sources */,
|
||||||
|
7174D80FB45839E82F150613 /* VelodyMacApp.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
384C8F5F47E4F21667BF35CD /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"DEBUG=1",
|
||||||
|
);
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_VERSION = 5.9;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
69FECBCBCA0B51ADDBD79E78 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = Velody;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = de.diyaa.velody.iphone;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
9478A6764E52B120F97FA0DF /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = "Velody Mac";
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
);
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = de.diyaa.velody.mac;
|
||||||
|
SDKROOT = macosx;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
C2A5BA68F4D6BC7151E5824A /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
SWIFT_VERSION = 5.9;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
D92AAC4C7558EFC853CF352F /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = "Velody Mac";
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
);
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = de.diyaa.velody.mac;
|
||||||
|
SDKROOT = macosx;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
DEE9A65FA6A370203099D8D0 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = Velody;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = de.diyaa.velody.iphone;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
6BBCD1EA2ADBFF580325615C /* Build configuration list for PBXNativeTarget "VelodyiPhone" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
69FECBCBCA0B51ADDBD79E78 /* Debug */,
|
||||||
|
DEE9A65FA6A370203099D8D0 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Debug;
|
||||||
|
};
|
||||||
|
A0556D22F619DDC41AA3C723 /* Build configuration list for PBXProject "Velody" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
384C8F5F47E4F21667BF35CD /* Debug */,
|
||||||
|
C2A5BA68F4D6BC7151E5824A /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Debug;
|
||||||
|
};
|
||||||
|
C3E3979BBD4515632B20BF81 /* Build configuration list for PBXNativeTarget "VelodyMac" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
D92AAC4C7558EFC853CF352F /* Debug */,
|
||||||
|
9478A6764E52B120F97FA0DF /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Debug;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
|
4290F294795FE24C6BF03B5B /* XCLocalSwiftPackageReference "../../packages/apple/VelodyUtilities" */ = {
|
||||||
|
isa = XCLocalSwiftPackageReference;
|
||||||
|
relativePath = ../../packages/apple/VelodyUtilities;
|
||||||
|
};
|
||||||
|
43AF8C7D2C6F46AB2748AAD9 /* XCLocalSwiftPackageReference "../../packages/apple/VelodyDomain" */ = {
|
||||||
|
isa = XCLocalSwiftPackageReference;
|
||||||
|
relativePath = ../../packages/apple/VelodyDomain;
|
||||||
|
};
|
||||||
|
5DC2C05FD0EC7BD980C0BF69 /* XCLocalSwiftPackageReference "../../packages/apple/VelodyNetworking" */ = {
|
||||||
|
isa = XCLocalSwiftPackageReference;
|
||||||
|
relativePath = ../../packages/apple/VelodyNetworking;
|
||||||
|
};
|
||||||
|
60A72563C2E87FC2BA78729A /* XCLocalSwiftPackageReference "../../packages/apple/VelodySync" */ = {
|
||||||
|
isa = XCLocalSwiftPackageReference;
|
||||||
|
relativePath = ../../packages/apple/VelodySync;
|
||||||
|
};
|
||||||
|
E9AE6C97FDD54CE81E584DDC /* XCLocalSwiftPackageReference "../../packages/apple/VelodyPersistence" */ = {
|
||||||
|
isa = XCLocalSwiftPackageReference;
|
||||||
|
relativePath = ../../packages/apple/VelodyPersistence;
|
||||||
|
};
|
||||||
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
0682A261A6F2F050F4B83AF6 /* VelodyNetworking */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = VelodyNetworking;
|
||||||
|
};
|
||||||
|
2449C403E81DD84D7A8DD7E1 /* VelodySync */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = VelodySync;
|
||||||
|
};
|
||||||
|
3C910108376E6ECEF152DCE1 /* VelodyDomain */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = VelodyDomain;
|
||||||
|
};
|
||||||
|
48BF8F8596E7A86383A9CCD1 /* VelodyDomain */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = VelodyDomain;
|
||||||
|
};
|
||||||
|
4DD09D7C123E184CEC0A2F4D /* VelodyPersistence */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = VelodyPersistence;
|
||||||
|
};
|
||||||
|
713279722B202FB1CF4A869E /* VelodyNetworking */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = VelodyNetworking;
|
||||||
|
};
|
||||||
|
AAD3F903A475AA0B0159C79E /* VelodyUtilities */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = VelodyUtilities;
|
||||||
|
};
|
||||||
|
B15F842ACBB110CC8A766669 /* VelodyUtilities */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = VelodyUtilities;
|
||||||
|
};
|
||||||
|
C8F5FF593C4DB829D1CDD497 /* VelodyPersistence */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = VelodyPersistence;
|
||||||
|
};
|
||||||
|
DA5EBADF45BC0977F73F241C /* VelodySync */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = VelodySync;
|
||||||
|
};
|
||||||
|
/* End XCSwiftPackageProductDependency section */
|
||||||
|
};
|
||||||
|
rootObject = D14070EEF27F35DD4F5F547B /* Project object */;
|
||||||
|
}
|
||||||
7
apps/apple/Velody.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
apps/apple/Velody.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
7
apps/apple/Velody.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
apps/apple/Velody.xcworkspace/contents.xcworkspacedata
generated
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "group:Velody.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
47
apps/apple/VelodyMac/Sources/FolderAccessService.swift
Normal file
47
apps/apple/VelodyMac/Sources/FolderAccessService.swift
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class FolderAccessService {
|
||||||
|
private let bookmarkKey = "velody.selected-folder.bookmark"
|
||||||
|
|
||||||
|
func chooseFolder() -> URL? {
|
||||||
|
let panel = NSOpenPanel()
|
||||||
|
panel.canChooseDirectories = true
|
||||||
|
panel.canChooseFiles = false
|
||||||
|
panel.allowsMultipleSelection = false
|
||||||
|
panel.prompt = "Choose Folder"
|
||||||
|
|
||||||
|
guard panel.runModal() == .OK, let url = panel.url else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let bookmark = try url.bookmarkData(
|
||||||
|
options: [.withSecurityScope],
|
||||||
|
includingResourceValuesForKeys: nil,
|
||||||
|
relativeTo: nil
|
||||||
|
)
|
||||||
|
UserDefaults.standard.set(bookmark, forKey: bookmarkKey)
|
||||||
|
return url
|
||||||
|
} catch {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func storedFolderURL() -> URL? {
|
||||||
|
guard
|
||||||
|
let bookmark = UserDefaults.standard.data(forKey: bookmarkKey)
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var isStale = false
|
||||||
|
return try? URL(
|
||||||
|
resolvingBookmarkData: bookmark,
|
||||||
|
options: [.withSecurityScope],
|
||||||
|
relativeTo: nil,
|
||||||
|
bookmarkDataIsStale: &isStale
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
56
apps/apple/VelodyMac/Sources/MacLibraryView.swift
Normal file
56
apps/apple/VelodyMac/Sources/MacLibraryView.swift
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import VelodyDomain
|
||||||
|
|
||||||
|
struct MacLibraryView: View {
|
||||||
|
@State private var viewModel = MacLibraryViewModel()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationSplitView {
|
||||||
|
List(viewModel.tracks) { track in
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(track.title)
|
||||||
|
.font(.headline)
|
||||||
|
Text(track.artist)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
if let album = track.album {
|
||||||
|
Text(album)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
.navigationTitle("Velody")
|
||||||
|
} detail: {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text("Private Library Foundation")
|
||||||
|
.font(.largeTitle)
|
||||||
|
Text("Selected folder")
|
||||||
|
.font(.headline)
|
||||||
|
Text(viewModel.selectedFolderPath)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
Text("Sync status")
|
||||||
|
.font(.headline)
|
||||||
|
Text(viewModel.syncStatus)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Button("Choose Watched Folder") {
|
||||||
|
viewModel.chooseFolder()
|
||||||
|
}
|
||||||
|
Button("Refresh Placeholder Sync") {
|
||||||
|
Task {
|
||||||
|
await viewModel.refreshSyncStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.loadIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
59
apps/apple/VelodyMac/Sources/MacLibraryViewModel.swift
Normal file
59
apps/apple/VelodyMac/Sources/MacLibraryViewModel.swift
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import VelodyDomain
|
||||||
|
import VelodyNetworking
|
||||||
|
import VelodyPersistence
|
||||||
|
import VelodySync
|
||||||
|
import VelodyUtilities
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class MacLibraryViewModel {
|
||||||
|
var tracks: [LibraryTrack] = []
|
||||||
|
var selectedFolderPath = "No folder selected"
|
||||||
|
var syncStatus = "Sync not started"
|
||||||
|
|
||||||
|
private let folderAccessService = FolderAccessService()
|
||||||
|
private let keychainService = MemoryKeychainService()
|
||||||
|
private let store = InMemoryLocalLibraryStore()
|
||||||
|
private let syncCoordinator: PlaceholderSyncCoordinator
|
||||||
|
private var hasLoaded = false
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let environment = ServerEnvironment(
|
||||||
|
baseURL: URL(string: "http://localhost:3000")!,
|
||||||
|
appVersion: "0.1.0"
|
||||||
|
)
|
||||||
|
let apiClient = StubVelodyAPIClient(environment: environment)
|
||||||
|
self.syncCoordinator = PlaceholderSyncCoordinator(
|
||||||
|
apiClient: apiClient,
|
||||||
|
store: store
|
||||||
|
)
|
||||||
|
if let url = folderAccessService.storedFolderURL() {
|
||||||
|
selectedFolderPath = url.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadIfNeeded() async {
|
||||||
|
guard !hasLoaded else { return }
|
||||||
|
hasLoaded = true
|
||||||
|
await refreshSyncStatus()
|
||||||
|
await keychainService.save("placeholder-bootstrap-token", forKey: "bootstrap-token")
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshSyncStatus() async {
|
||||||
|
do {
|
||||||
|
let result = try await syncCoordinator.performInitialSync()
|
||||||
|
tracks = result.tracks
|
||||||
|
syncStatus = result.statusMessage
|
||||||
|
} catch {
|
||||||
|
syncStatus = "Sync placeholder failed: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func chooseFolder() {
|
||||||
|
if let url = folderAccessService.chooseFolder() {
|
||||||
|
selectedFolderPath = url.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/apple/VelodyMac/Sources/VelodyMacApp.swift
Normal file
10
apps/apple/VelodyMac/Sources/VelodyMacApp.swift
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct VelodyMacApp: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
MacLibraryView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/apple/VelodyiPhone/Sources/VelodyiPhoneApp.swift
Normal file
10
apps/apple/VelodyiPhone/Sources/VelodyiPhoneApp.swift
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct VelodyiPhoneApp: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
iPhoneLibraryView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift
Normal file
48
apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct iPhoneLibraryView: View {
|
||||||
|
@State private var viewModel = iPhoneLibraryViewModel()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List(viewModel.tracks) { track in
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(track.title)
|
||||||
|
.font(.headline)
|
||||||
|
Text(track.artist)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
if viewModel.tracks.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No Local Tracks Yet",
|
||||||
|
systemImage: "music.note.list",
|
||||||
|
description: Text("This iPhone target currently exposes the offline catalog shell only.")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Velody")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("Sync") {
|
||||||
|
Task {
|
||||||
|
await viewModel.refreshSync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.safeAreaInset(edge: .bottom) {
|
||||||
|
Text(viewModel.syncStatus)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(.ultraThinMaterial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.loadIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift
Normal file
48
apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import VelodyDomain
|
||||||
|
import VelodyNetworking
|
||||||
|
import VelodyPersistence
|
||||||
|
import VelodySync
|
||||||
|
import VelodyUtilities
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class iPhoneLibraryViewModel {
|
||||||
|
var tracks: [LibraryTrack] = []
|
||||||
|
var syncStatus = "Offline library not synced yet"
|
||||||
|
|
||||||
|
private let store = InMemoryLocalLibraryStore()
|
||||||
|
private let keychainService = MemoryKeychainService()
|
||||||
|
private let syncCoordinator: PlaceholderSyncCoordinator
|
||||||
|
private var hasLoaded = false
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let environment = ServerEnvironment(
|
||||||
|
baseURL: URL(string: "http://localhost:3000")!,
|
||||||
|
appVersion: "0.1.0"
|
||||||
|
)
|
||||||
|
let apiClient = StubVelodyAPIClient(environment: environment)
|
||||||
|
self.syncCoordinator = PlaceholderSyncCoordinator(
|
||||||
|
apiClient: apiClient,
|
||||||
|
store: store
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadIfNeeded() async {
|
||||||
|
guard !hasLoaded else { return }
|
||||||
|
hasLoaded = true
|
||||||
|
await keychainService.save("placeholder-bootstrap-token", forKey: "bootstrap-token")
|
||||||
|
await refreshSync()
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshSync() async {
|
||||||
|
do {
|
||||||
|
let result = try await syncCoordinator.performInitialSync()
|
||||||
|
tracks = result.tracks
|
||||||
|
syncStatus = result.statusMessage
|
||||||
|
} catch {
|
||||||
|
syncStatus = "Sync placeholder failed: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
64
apps/apple/project.yml
Normal file
64
apps/apple/project.yml
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
name: Velody
|
||||||
|
options:
|
||||||
|
bundleIdPrefix: de.diyaa.velody
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
SWIFT_VERSION: 5.9
|
||||||
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
packages:
|
||||||
|
VelodyDomain:
|
||||||
|
path: ../../packages/apple/VelodyDomain
|
||||||
|
VelodyNetworking:
|
||||||
|
path: ../../packages/apple/VelodyNetworking
|
||||||
|
VelodyPersistence:
|
||||||
|
path: ../../packages/apple/VelodyPersistence
|
||||||
|
VelodySync:
|
||||||
|
path: ../../packages/apple/VelodySync
|
||||||
|
VelodyUtilities:
|
||||||
|
path: ../../packages/apple/VelodyUtilities
|
||||||
|
targets:
|
||||||
|
VelodyMac:
|
||||||
|
type: application
|
||||||
|
platform: macOS
|
||||||
|
deploymentTarget: "14.0"
|
||||||
|
sources:
|
||||||
|
- path: VelodyMac/Sources
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: de.diyaa.velody.mac
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName: Velody Mac
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType: public.app-category.music
|
||||||
|
MACOSX_DEPLOYMENT_TARGET: 14.0
|
||||||
|
dependencies:
|
||||||
|
- package: VelodyDomain
|
||||||
|
product: VelodyDomain
|
||||||
|
- package: VelodyNetworking
|
||||||
|
product: VelodyNetworking
|
||||||
|
- package: VelodyPersistence
|
||||||
|
product: VelodyPersistence
|
||||||
|
- package: VelodySync
|
||||||
|
product: VelodySync
|
||||||
|
- package: VelodyUtilities
|
||||||
|
product: VelodyUtilities
|
||||||
|
VelodyiPhone:
|
||||||
|
type: application
|
||||||
|
platform: iOS
|
||||||
|
deploymentTarget: "17.0"
|
||||||
|
sources:
|
||||||
|
- path: VelodyiPhone/Sources
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: de.diyaa.velody.iphone
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName: Velody
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET: 17.0
|
||||||
|
dependencies:
|
||||||
|
- package: VelodyDomain
|
||||||
|
product: VelodyDomain
|
||||||
|
- package: VelodyNetworking
|
||||||
|
product: VelodyNetworking
|
||||||
|
- package: VelodyPersistence
|
||||||
|
product: VelodyPersistence
|
||||||
|
- package: VelodySync
|
||||||
|
product: VelodySync
|
||||||
|
- package: VelodyUtilities
|
||||||
|
product: VelodyUtilities
|
||||||
4
backend/.dockerignore
Normal file
4
backend/.dockerignore
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
.env
|
||||||
7
backend/.env.example
Normal file
7
backend/.env.example
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
NODE_ENV=development
|
||||||
|
PORT=3000
|
||||||
|
DATABASE_URL=postgresql://velody:velody@localhost:5432/velody?schema=public
|
||||||
|
STORAGE_ROOT=../runtime/storage
|
||||||
|
PUBLIC_BASE_URL=http://localhost:3000
|
||||||
|
DEVICE_BOOTSTRAP_SECRET=replace-me
|
||||||
|
MAX_UPLOAD_SIZE_BYTES=524288000
|
||||||
28
backend/Dockerfile
Normal file
28
backend/Dockerfile
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY backend/package.json backend/package-lock.json* ./backend/
|
||||||
|
WORKDIR /app/backend
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY backend ./
|
||||||
|
RUN npx prisma generate
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app/backend
|
||||||
|
|
||||||
|
COPY --from=builder /app/backend/package.json ./
|
||||||
|
COPY --from=builder /app/backend/package-lock.json* ./
|
||||||
|
COPY --from=builder /app/backend/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/backend/dist ./dist
|
||||||
|
COPY --from=builder /app/backend/prisma ./prisma
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY runtime ./runtime
|
||||||
|
|
||||||
|
WORKDIR /app/backend
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main.js"]
|
||||||
12
backend/jest.config.js
Normal file
12
backend/jest.config.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
module.exports = {
|
||||||
|
moduleFileExtensions: ["js", "json", "ts"],
|
||||||
|
rootDir: ".",
|
||||||
|
testRegex: ".*\\.spec\\.ts$",
|
||||||
|
setupFiles: ["<rootDir>/test/setup-env.ts"],
|
||||||
|
transform: {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest",
|
||||||
|
},
|
||||||
|
collectCoverageFrom: ["src/**/*.ts"],
|
||||||
|
coverageDirectory: "./coverage",
|
||||||
|
testEnvironment: "node",
|
||||||
|
};
|
||||||
599
backend/openapi/velody.openapi.json
Normal file
599
backend/openapi/velody.openapi.json
Normal file
@ -0,0 +1,599 @@
|
|||||||
|
{
|
||||||
|
"openapi": "3.0.0",
|
||||||
|
"paths": {
|
||||||
|
"/api/v1/health": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "HealthController_getHealth_v1",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HealthResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"health"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/devices/register": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "DevicesController_register_v1",
|
||||||
|
"parameters": [],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RegisterDeviceRequestDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RegisterDeviceResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"devices"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/devices/heartbeat": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "DevicesController_heartbeat_v1",
|
||||||
|
"parameters": [],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/DeviceHeartbeatRequestDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/DeviceHeartbeatResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"devices"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/uploads/prepare": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "UploadsController_prepare_v1",
|
||||||
|
"parameters": [],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/UploadPrepareRequestDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/UploadPrepareResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"uploads"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/uploads/{uploadId}": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "UploadsController_getStatus_v1",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "uploadId",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/UploadSessionStatusResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"uploads"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/uploads/{uploadId}/finalize": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "UploadsController_finalize_v1",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/UploadFinalizeResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "Reserved for the next milestone",
|
||||||
|
"tags": [
|
||||||
|
"uploads"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/sync/bootstrap": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "SyncController_bootstrap_v1",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SyncBootstrapResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"sync"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/sync/changes": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "SyncController_changes_v1",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "after",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"example": "0",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SyncChangesResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"sync"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"title": "Velody API",
|
||||||
|
"description": "Velody Phase 1 foundation API",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"contact": {}
|
||||||
|
},
|
||||||
|
"tags": [],
|
||||||
|
"servers": [],
|
||||||
|
"components": {
|
||||||
|
"schemas": {
|
||||||
|
"HealthDependencyDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "up"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"status"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"StorageDependencyDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "up"
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "/app/runtime/storage"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"status",
|
||||||
|
"root"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"HealthResponseDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"service": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "velody-backend"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "0.1.0"
|
||||||
|
},
|
||||||
|
"database": {
|
||||||
|
"$ref": "#/components/schemas/HealthDependencyDto"
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"$ref": "#/components/schemas/StorageDependencyDto"
|
||||||
|
},
|
||||||
|
"serverTime": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "2026-05-24T20:00:00.000Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"service",
|
||||||
|
"version",
|
||||||
|
"database",
|
||||||
|
"storage",
|
||||||
|
"serverTime"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"RegisterDeviceRequestDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"platform": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"MACOS",
|
||||||
|
"IPHONE"
|
||||||
|
],
|
||||||
|
"example": "MACOS"
|
||||||
|
},
|
||||||
|
"deviceName": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Diya MacBook Pro"
|
||||||
|
},
|
||||||
|
"appVersion": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "0.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"platform",
|
||||||
|
"deviceName",
|
||||||
|
"appVersion"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"RegisterDeviceResponseDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"deviceId": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"bootstrapToken": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"serverTime": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "2026-05-24T20:00:00.000Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"deviceId",
|
||||||
|
"bootstrapToken",
|
||||||
|
"serverTime"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"DeviceHeartbeatRequestDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"deviceId": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"appVersion": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "0.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"deviceId",
|
||||||
|
"appVersion"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"DeviceHeartbeatResponseDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ok": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"serverTime": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "2026-05-24T20:00:00.000Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"ok",
|
||||||
|
"serverTime"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"UploadPrepareRequestDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"deviceId": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"sha256": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
},
|
||||||
|
"originalFilename": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "track.mp3"
|
||||||
|
},
|
||||||
|
"sizeBytes": {
|
||||||
|
"type": "number",
|
||||||
|
"example": 10485760
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"deviceId",
|
||||||
|
"sha256",
|
||||||
|
"originalFilename",
|
||||||
|
"sizeBytes"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"UploadPrepareResponseDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"exists",
|
||||||
|
"upload_required"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"uploadId": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"nextOffset": {
|
||||||
|
"type": "number",
|
||||||
|
"example": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"status"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"UploadSessionStatusResponseDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uploadId": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"PENDING",
|
||||||
|
"READY_TO_UPLOAD",
|
||||||
|
"COMPLETED",
|
||||||
|
"FAILED"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"receivedBytes": {
|
||||||
|
"type": "string",
|
||||||
|
"example": 0
|
||||||
|
},
|
||||||
|
"expectedSizeBytes": {
|
||||||
|
"type": "string",
|
||||||
|
"example": 10485760
|
||||||
|
},
|
||||||
|
"nextOffset": {
|
||||||
|
"type": "string",
|
||||||
|
"example": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"uploadId",
|
||||||
|
"status",
|
||||||
|
"receivedBytes",
|
||||||
|
"expectedSizeBytes",
|
||||||
|
"nextOffset"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"UploadFinalizeResponseDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"statusCode": {
|
||||||
|
"type": "number",
|
||||||
|
"example": 501
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Upload finalization is not implemented yet."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"statusCode",
|
||||||
|
"message"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"LibraryTrackDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Placeholder Track"
|
||||||
|
},
|
||||||
|
"artist": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Velody"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SyncEventDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"entityType": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "TRACK"
|
||||||
|
},
|
||||||
|
"entityId": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "CREATED"
|
||||||
|
},
|
||||||
|
"eventId": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"entityType",
|
||||||
|
"entityId",
|
||||||
|
"action",
|
||||||
|
"eventId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"SyncBootstrapResponseDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"nextCursor": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "0"
|
||||||
|
},
|
||||||
|
"tracks": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/LibraryTrackDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/SyncEventDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"deletedTrackIds": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"serverTime": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "2026-05-24T20:00:00.000Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"nextCursor",
|
||||||
|
"tracks",
|
||||||
|
"events",
|
||||||
|
"deletedTrackIds",
|
||||||
|
"serverTime"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"SyncChangesResponseDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"nextCursor": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "0"
|
||||||
|
},
|
||||||
|
"tracks": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/LibraryTrackDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/SyncEventDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"deletedTrackIds": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"serverTime": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "2026-05-24T20:00:00.000Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"nextCursor",
|
||||||
|
"tracks",
|
||||||
|
"events",
|
||||||
|
"deletedTrackIds",
|
||||||
|
"serverTime"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7067
backend/package-lock.json
generated
Normal file
7067
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
backend/package.json
Normal file
44
backend/package.json
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "velody-backend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Velody Phase 1 backend foundation",
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.build.json",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"start:dev": "node --watch --enable-source-maps dist/main.js",
|
||||||
|
"test": "jest --runInBand",
|
||||||
|
"test:e2e": "jest --config test/jest-e2e.json --runInBand",
|
||||||
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:migrate:deploy": "prisma migrate deploy",
|
||||||
|
"prisma:migrate:dev": "prisma migrate dev",
|
||||||
|
"openapi:json": "ts-node --project tsconfig.json scripts/generate-openapi.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^11.0.0",
|
||||||
|
"@nestjs/config": "^4.0.0",
|
||||||
|
"@nestjs/core": "^11.0.0",
|
||||||
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
|
"@nestjs/swagger": "^11.0.0",
|
||||||
|
"@prisma/client": "^6.0.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.1",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"swagger-ui-express": "^5.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/testing": "^11.0.0",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"@types/supertest": "^6.0.2",
|
||||||
|
"jest": "^30.0.0",
|
||||||
|
"prisma": "^6.0.0",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
|
"ts-jest": "^29.4.0",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.8.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,138 @@
|
|||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
|
|
||||||
|
CREATE TYPE "DevicePlatform" AS ENUM ('MACOS', 'IPHONE');
|
||||||
|
CREATE TYPE "TrackStatus" AS ENUM ('ACTIVE', 'DELETED');
|
||||||
|
CREATE TYPE "UploadSessionStatus" AS ENUM ('PENDING', 'READY_TO_UPLOAD', 'COMPLETED', 'FAILED');
|
||||||
|
CREATE TYPE "EntityType" AS ENUM ('TRACK', 'AUDIO_ASSET', 'ARTWORK_ASSET');
|
||||||
|
CREATE TYPE "EventAction" AS ENUM ('CREATED', 'UPDATED', 'DELETED');
|
||||||
|
|
||||||
|
CREATE TABLE "devices" (
|
||||||
|
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"platform" "DevicePlatform" NOT NULL,
|
||||||
|
"device_name" TEXT NOT NULL,
|
||||||
|
"app_version" TEXT NOT NULL,
|
||||||
|
"install_token_hash" TEXT NOT NULL,
|
||||||
|
"last_seen_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "devices_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "tracks" (
|
||||||
|
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"primary_audio_asset_id" UUID,
|
||||||
|
"artwork_asset_id" UUID,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"artist" TEXT NOT NULL,
|
||||||
|
"album" TEXT,
|
||||||
|
"album_artist" TEXT,
|
||||||
|
"genre" TEXT,
|
||||||
|
"disc_number" INTEGER,
|
||||||
|
"track_number" INTEGER,
|
||||||
|
"year" INTEGER,
|
||||||
|
"duration_ms" INTEGER,
|
||||||
|
"status" "TrackStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"deleted_at" TIMESTAMP(3),
|
||||||
|
CONSTRAINT "tracks_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "audio_assets" (
|
||||||
|
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"track_id" UUID,
|
||||||
|
"sha256" TEXT NOT NULL,
|
||||||
|
"storage_key" TEXT NOT NULL,
|
||||||
|
"original_filename" TEXT NOT NULL,
|
||||||
|
"mime_type" TEXT NOT NULL,
|
||||||
|
"file_extension" TEXT NOT NULL,
|
||||||
|
"file_size_bytes" BIGINT NOT NULL,
|
||||||
|
"bit_rate_kbps" INTEGER,
|
||||||
|
"sample_rate_hz" INTEGER,
|
||||||
|
"channels" INTEGER,
|
||||||
|
"duration_ms" INTEGER,
|
||||||
|
"source_device_id" UUID,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "audio_assets_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "artwork_assets" (
|
||||||
|
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"sha256" TEXT NOT NULL,
|
||||||
|
"storage_key" TEXT NOT NULL,
|
||||||
|
"mime_type" TEXT NOT NULL,
|
||||||
|
"width" INTEGER,
|
||||||
|
"height" INTEGER,
|
||||||
|
"file_size_bytes" BIGINT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "artwork_assets_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "upload_sessions" (
|
||||||
|
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"device_id" UUID NOT NULL,
|
||||||
|
"expected_sha256" TEXT NOT NULL,
|
||||||
|
"expected_size_bytes" BIGINT NOT NULL,
|
||||||
|
"received_bytes" BIGINT NOT NULL DEFAULT 0,
|
||||||
|
"temp_storage_path" TEXT NOT NULL,
|
||||||
|
"status" "UploadSessionStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "upload_sessions_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "library_events" (
|
||||||
|
"id" BIGSERIAL NOT NULL,
|
||||||
|
"entity_type" "EntityType" NOT NULL,
|
||||||
|
"entity_id" UUID NOT NULL,
|
||||||
|
"action" "EventAction" NOT NULL,
|
||||||
|
"payload_version" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "library_events_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "device_sync_cursors" (
|
||||||
|
"device_id" UUID NOT NULL,
|
||||||
|
"last_event_id" BIGINT NOT NULL DEFAULT 0,
|
||||||
|
"last_full_sync_at" TIMESTAMP(3),
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "device_sync_cursors_pkey" PRIMARY KEY ("device_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "tracks_primary_audio_asset_id_key" ON "tracks"("primary_audio_asset_id");
|
||||||
|
CREATE UNIQUE INDEX "tracks_artwork_asset_id_key" ON "tracks"("artwork_asset_id");
|
||||||
|
CREATE UNIQUE INDEX "audio_assets_sha256_key" ON "audio_assets"("sha256");
|
||||||
|
CREATE UNIQUE INDEX "audio_assets_storage_key" ON "audio_assets"("storage_key");
|
||||||
|
CREATE UNIQUE INDEX "artwork_assets_sha256_key" ON "artwork_assets"("sha256");
|
||||||
|
CREATE UNIQUE INDEX "artwork_assets_storage_key" ON "artwork_assets"("storage_key");
|
||||||
|
|
||||||
|
ALTER TABLE "audio_assets"
|
||||||
|
ADD CONSTRAINT "audio_assets_track_id_fkey"
|
||||||
|
FOREIGN KEY ("track_id") REFERENCES "tracks"("id")
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "audio_assets"
|
||||||
|
ADD CONSTRAINT "audio_assets_source_device_id_fkey"
|
||||||
|
FOREIGN KEY ("source_device_id") REFERENCES "devices"("id")
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "tracks"
|
||||||
|
ADD CONSTRAINT "tracks_primary_audio_asset_id_fkey"
|
||||||
|
FOREIGN KEY ("primary_audio_asset_id") REFERENCES "audio_assets"("id")
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "tracks"
|
||||||
|
ADD CONSTRAINT "tracks_artwork_asset_id_fkey"
|
||||||
|
FOREIGN KEY ("artwork_asset_id") REFERENCES "artwork_assets"("id")
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "upload_sessions"
|
||||||
|
ADD CONSTRAINT "upload_sessions_device_id_fkey"
|
||||||
|
FOREIGN KEY ("device_id") REFERENCES "devices"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "device_sync_cursors"
|
||||||
|
ADD CONSTRAINT "device_sync_cursors_device_id_fkey"
|
||||||
|
FOREIGN KEY ("device_id") REFERENCES "devices"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
2
backend/prisma/migrations/migration_lock.toml
Normal file
2
backend/prisma/migrations/migration_lock.toml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
provider = "postgresql"
|
||||||
150
backend/prisma/schema.prisma
Normal file
150
backend/prisma/schema.prisma
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Device {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
platform DevicePlatform
|
||||||
|
deviceName String @map("device_name")
|
||||||
|
appVersion String @map("app_version")
|
||||||
|
installTokenHash String @map("install_token_hash")
|
||||||
|
lastSeenAt DateTime @map("last_seen_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
uploadSessions UploadSession[]
|
||||||
|
syncCursor DeviceSyncCursor?
|
||||||
|
audioAssets AudioAsset[]
|
||||||
|
|
||||||
|
@@map("devices")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Track {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
primaryAudioAssetId String? @unique @db.Uuid @map("primary_audio_asset_id")
|
||||||
|
artworkAssetId String? @unique @db.Uuid @map("artwork_asset_id")
|
||||||
|
title String
|
||||||
|
artist String
|
||||||
|
album String?
|
||||||
|
albumArtist String? @map("album_artist")
|
||||||
|
genre String?
|
||||||
|
discNumber Int? @map("disc_number")
|
||||||
|
trackNumber Int? @map("track_number")
|
||||||
|
year Int?
|
||||||
|
durationMs Int? @map("duration_ms")
|
||||||
|
status TrackStatus @default(ACTIVE)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
primaryAudioAsset AudioAsset? @relation("PrimaryAudioAsset", fields: [primaryAudioAssetId], references: [id], onDelete: SetNull, onUpdate: Cascade)
|
||||||
|
artworkAsset ArtworkAsset? @relation("TrackArtwork", fields: [artworkAssetId], references: [id], onDelete: SetNull, onUpdate: Cascade)
|
||||||
|
audioAssets AudioAsset[] @relation("TrackAudioAssets")
|
||||||
|
|
||||||
|
@@map("tracks")
|
||||||
|
}
|
||||||
|
|
||||||
|
model AudioAsset {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
trackId String? @db.Uuid @map("track_id")
|
||||||
|
sha256 String @unique
|
||||||
|
storageKey String @unique @map("storage_key")
|
||||||
|
originalFilename String @map("original_filename")
|
||||||
|
mimeType String @map("mime_type")
|
||||||
|
fileExtension String @map("file_extension")
|
||||||
|
fileSizeBytes BigInt @map("file_size_bytes")
|
||||||
|
bitRateKbps Int? @map("bit_rate_kbps")
|
||||||
|
sampleRateHz Int? @map("sample_rate_hz")
|
||||||
|
channels Int?
|
||||||
|
durationMs Int? @map("duration_ms")
|
||||||
|
sourceDeviceId String? @db.Uuid @map("source_device_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
track Track? @relation("TrackAudioAssets", fields: [trackId], references: [id], onDelete: SetNull, onUpdate: Cascade)
|
||||||
|
primaryForTrack Track? @relation("PrimaryAudioAsset")
|
||||||
|
sourceDevice Device? @relation(fields: [sourceDeviceId], references: [id], onDelete: SetNull, onUpdate: Cascade)
|
||||||
|
|
||||||
|
@@map("audio_assets")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ArtworkAsset {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
sha256 String @unique
|
||||||
|
storageKey String @unique @map("storage_key")
|
||||||
|
mimeType String @map("mime_type")
|
||||||
|
width Int?
|
||||||
|
height Int?
|
||||||
|
fileSizeBytes BigInt @map("file_size_bytes")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
track Track? @relation("TrackArtwork")
|
||||||
|
|
||||||
|
@@map("artwork_assets")
|
||||||
|
}
|
||||||
|
|
||||||
|
model UploadSession {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
deviceId String @db.Uuid @map("device_id")
|
||||||
|
expectedSha256 String @map("expected_sha256")
|
||||||
|
expectedSizeBytes BigInt @map("expected_size_bytes")
|
||||||
|
receivedBytes BigInt @default(0) @map("received_bytes")
|
||||||
|
tempStoragePath String @map("temp_storage_path")
|
||||||
|
status UploadSessionStatus @default(PENDING)
|
||||||
|
expiresAt DateTime @map("expires_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||||
|
|
||||||
|
@@map("upload_sessions")
|
||||||
|
}
|
||||||
|
|
||||||
|
model LibraryEvent {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
entityType EntityType @map("entity_type")
|
||||||
|
entityId String @db.Uuid @map("entity_id")
|
||||||
|
action EventAction
|
||||||
|
payloadVersion Int @default(1) @map("payload_version")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@map("library_events")
|
||||||
|
}
|
||||||
|
|
||||||
|
model DeviceSyncCursor {
|
||||||
|
deviceId String @id @db.Uuid @map("device_id")
|
||||||
|
lastEventId BigInt @default(0) @map("last_event_id")
|
||||||
|
lastFullSyncAt DateTime? @map("last_full_sync_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||||
|
|
||||||
|
@@map("device_sync_cursors")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DevicePlatform {
|
||||||
|
MACOS
|
||||||
|
IPHONE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TrackStatus {
|
||||||
|
ACTIVE
|
||||||
|
DELETED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UploadSessionStatus {
|
||||||
|
PENDING
|
||||||
|
READY_TO_UPLOAD
|
||||||
|
COMPLETED
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EntityType {
|
||||||
|
TRACK
|
||||||
|
AUDIO_ASSET
|
||||||
|
ARTWORK_ASSET
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EventAction {
|
||||||
|
CREATED
|
||||||
|
UPDATED
|
||||||
|
DELETED
|
||||||
|
}
|
||||||
36
backend/scripts/generate-openapi.ts
Normal file
36
backend/scripts/generate-openapi.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
async function generate(): Promise<void> {
|
||||||
|
process.env.NODE_ENV ??= 'development';
|
||||||
|
process.env.PORT ??= '3000';
|
||||||
|
process.env.DATABASE_URL ??=
|
||||||
|
'postgresql://velody:velody@localhost:5432/velody?schema=public';
|
||||||
|
process.env.STORAGE_ROOT ??= join(process.cwd(), '..', 'runtime', 'storage');
|
||||||
|
process.env.PUBLIC_BASE_URL ??= 'http://localhost:3000';
|
||||||
|
process.env.DEVICE_BOOTSTRAP_SECRET ??= 'openapi-placeholder-secret';
|
||||||
|
process.env.MAX_UPLOAD_SIZE_BYTES ??= '524288000';
|
||||||
|
|
||||||
|
const { createApp } = await import('../src/app.factory');
|
||||||
|
const app = await createApp();
|
||||||
|
const document = SwaggerModule.createDocument(
|
||||||
|
app,
|
||||||
|
new DocumentBuilder()
|
||||||
|
.setTitle('Velody API')
|
||||||
|
.setDescription('Velody Phase 1 foundation API')
|
||||||
|
.setVersion('1.0.0')
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const outputDir = join(process.cwd(), 'openapi');
|
||||||
|
await mkdir(outputDir, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
join(outputDir, 'velody.openapi.json'),
|
||||||
|
JSON.stringify(document, null, 2),
|
||||||
|
);
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
void generate();
|
||||||
35
backend/src/app.factory.ts
Normal file
35
backend/src/app.factory.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { ValidationPipe, VersioningType } from '@nestjs/common';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
export async function createApp() {
|
||||||
|
const app = await NestFactory.create(AppModule, { bufferLogs: true });
|
||||||
|
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
|
app.enableVersioning({
|
||||||
|
type: VersioningType.URI,
|
||||||
|
});
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const document = SwaggerModule.createDocument(
|
||||||
|
app,
|
||||||
|
new DocumentBuilder()
|
||||||
|
.setTitle('Velody API')
|
||||||
|
.setDescription('Velody Phase 1 foundation API')
|
||||||
|
.setVersion('1.0.0')
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
|
||||||
|
SwaggerModule.setup('api/docs', app, document, {
|
||||||
|
jsonDocumentUrl: 'api/docs-json',
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
19
backend/src/app.module.ts
Normal file
19
backend/src/app.module.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AppConfigModule } from './modules/config/config.module';
|
||||||
|
import { DevicesModule } from './modules/devices/devices.module';
|
||||||
|
import { HealthModule } from './modules/health/health.module';
|
||||||
|
import { LibraryModule } from './modules/library/library.module';
|
||||||
|
import { SyncModule } from './modules/sync/sync.module';
|
||||||
|
import { UploadsModule } from './modules/uploads/uploads.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
AppConfigModule,
|
||||||
|
HealthModule,
|
||||||
|
DevicesModule,
|
||||||
|
UploadsModule,
|
||||||
|
LibraryModule,
|
||||||
|
SyncModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
9
backend/src/infrastructure/database/prisma.module.ts
Normal file
9
backend/src/infrastructure/database/prisma.module.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [PrismaService],
|
||||||
|
exports: [PrismaService],
|
||||||
|
})
|
||||||
|
export class PrismaModule {}
|
||||||
9
backend/src/infrastructure/database/prisma.service.ts
Normal file
9
backend/src/infrastructure/database/prisma.service.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaService extends PrismaClient implements OnModuleInit {
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
await this.$connect();
|
||||||
|
}
|
||||||
|
}
|
||||||
11
backend/src/main.ts
Normal file
11
backend/src/main.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { createApp } from './app.factory';
|
||||||
|
import { AppConfigService } from './modules/config/config.service';
|
||||||
|
|
||||||
|
async function bootstrap(): Promise<void> {
|
||||||
|
const app = await createApp();
|
||||||
|
const port = app.get(AppConfigService).port;
|
||||||
|
await app.listen(port);
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap();
|
||||||
21
backend/src/modules/config/config.module.ts
Normal file
21
backend/src/modules/config/config.module.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||||
|
import { AppConfigService } from './config.service';
|
||||||
|
import { validateEnvironment } from './environment';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
envFilePath: [join(process.cwd(), '.env'), join(process.cwd(), '..', '.env')],
|
||||||
|
validate: validateEnvironment,
|
||||||
|
}),
|
||||||
|
PrismaModule,
|
||||||
|
],
|
||||||
|
providers: [AppConfigService],
|
||||||
|
exports: [AppConfigService],
|
||||||
|
})
|
||||||
|
export class AppConfigModule {}
|
||||||
47
backend/src/modules/config/config.service.ts
Normal file
47
backend/src/modules/config/config.service.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AppConfigService {
|
||||||
|
constructor(private readonly configService: ConfigService) {}
|
||||||
|
|
||||||
|
get nodeEnv(): string {
|
||||||
|
return this.required('NODE_ENV');
|
||||||
|
}
|
||||||
|
|
||||||
|
get port(): number {
|
||||||
|
return Number(this.required('PORT'));
|
||||||
|
}
|
||||||
|
|
||||||
|
get databaseUrl(): string {
|
||||||
|
return this.required('DATABASE_URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
get storageRoot(): string {
|
||||||
|
return this.required('STORAGE_ROOT');
|
||||||
|
}
|
||||||
|
|
||||||
|
get publicBaseUrl(): string {
|
||||||
|
return this.required('PUBLIC_BASE_URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
get deviceBootstrapSecret(): string {
|
||||||
|
return this.required('DEVICE_BOOTSTRAP_SECRET');
|
||||||
|
}
|
||||||
|
|
||||||
|
get maxUploadSizeBytes(): number {
|
||||||
|
return Number(this.required('MAX_UPLOAD_SIZE_BYTES'));
|
||||||
|
}
|
||||||
|
|
||||||
|
get appVersion(): string {
|
||||||
|
return process.env.npm_package_version ?? '0.1.0';
|
||||||
|
}
|
||||||
|
|
||||||
|
private required(key: string): string {
|
||||||
|
const value = this.configService.get<string>(key);
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`Missing required configuration value for ${key}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
backend/src/modules/config/environment.spec.ts
Normal file
26
backend/src/modules/config/environment.spec.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { validateEnvironment } from './environment';
|
||||||
|
|
||||||
|
describe('validateEnvironment', () => {
|
||||||
|
it('accepts a valid environment', () => {
|
||||||
|
const result = validateEnvironment({
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
PORT: '3000',
|
||||||
|
DATABASE_URL: 'postgresql://velody:velody@localhost:5432/velody?schema=public',
|
||||||
|
STORAGE_ROOT: '/tmp/velody',
|
||||||
|
PUBLIC_BASE_URL: 'http://localhost:3000',
|
||||||
|
DEVICE_BOOTSTRAP_SECRET: 'secret',
|
||||||
|
MAX_UPLOAD_SIZE_BYTES: '1024',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.PORT).toBe(3000);
|
||||||
|
expect(result.MAX_UPLOAD_SIZE_BYTES).toBe(1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for missing values', () => {
|
||||||
|
expect(() =>
|
||||||
|
validateEnvironment({
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
}),
|
||||||
|
).toThrow(/Invalid environment configuration/);
|
||||||
|
});
|
||||||
|
});
|
||||||
65
backend/src/modules/config/environment.ts
Normal file
65
backend/src/modules/config/environment.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { plainToInstance } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsInt,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsString,
|
||||||
|
IsUrl,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
validateSync,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
class EnvironmentVariables {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
NODE_ENV!: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(65535)
|
||||||
|
PORT!: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
DATABASE_URL!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
STORAGE_ROOT!: string;
|
||||||
|
|
||||||
|
@IsUrl({
|
||||||
|
require_tld: false,
|
||||||
|
require_protocol: true,
|
||||||
|
})
|
||||||
|
PUBLIC_BASE_URL!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
DEVICE_BOOTSTRAP_SECRET!: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
MAX_UPLOAD_SIZE_BYTES!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateEnvironment(config: Record<string, unknown>) {
|
||||||
|
const validated = plainToInstance(EnvironmentVariables, config, {
|
||||||
|
enableImplicitConversion: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const errors = validateSync(validated, {
|
||||||
|
skipMissingProperties: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid environment configuration: ${errors
|
||||||
|
.map((error) => Object.values(error.constraints ?? {}).join(', '))
|
||||||
|
.join('; ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return validated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ValidEnvironment = ReturnType<typeof validateEnvironment>;
|
||||||
34
backend/src/modules/devices/devices.controller.ts
Normal file
34
backend/src/modules/devices/devices.controller.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { Body, Controller, Post } from '@nestjs/common';
|
||||||
|
import { ApiCreatedResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
DeviceHeartbeatRequestDto,
|
||||||
|
DeviceHeartbeatResponseDto,
|
||||||
|
RegisterDeviceRequestDto,
|
||||||
|
RegisterDeviceResponseDto,
|
||||||
|
} from './devices.dto';
|
||||||
|
import { DevicesService } from './devices.service';
|
||||||
|
|
||||||
|
@ApiTags('devices')
|
||||||
|
@Controller({
|
||||||
|
path: 'devices',
|
||||||
|
version: '1',
|
||||||
|
})
|
||||||
|
export class DevicesController {
|
||||||
|
constructor(private readonly devicesService: DevicesService) {}
|
||||||
|
|
||||||
|
@Post('register')
|
||||||
|
@ApiCreatedResponse({ type: RegisterDeviceResponseDto })
|
||||||
|
async register(
|
||||||
|
@Body() body: RegisterDeviceRequestDto,
|
||||||
|
): Promise<RegisterDeviceResponseDto> {
|
||||||
|
return this.devicesService.register(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('heartbeat')
|
||||||
|
@ApiOkResponse({ type: DeviceHeartbeatResponseDto })
|
||||||
|
async heartbeat(
|
||||||
|
@Body() body: DeviceHeartbeatRequestDto,
|
||||||
|
): Promise<DeviceHeartbeatResponseDto> {
|
||||||
|
return this.devicesService.heartbeat(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
backend/src/modules/devices/devices.dto.ts
Normal file
49
backend/src/modules/devices/devices.dto.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { DevicePlatform } from '@prisma/client';
|
||||||
|
import { IsEnum, IsString, IsUUID, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class RegisterDeviceRequestDto {
|
||||||
|
@ApiProperty({ enum: DevicePlatform, example: DevicePlatform.MACOS })
|
||||||
|
@IsEnum(DevicePlatform)
|
||||||
|
platform!: DevicePlatform;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Diya MacBook Pro' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
deviceName!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '0.1.0' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
appVersion!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RegisterDeviceResponseDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
deviceId!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
bootstrapToken!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2026-05-24T20:00:00.000Z' })
|
||||||
|
serverTime!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeviceHeartbeatRequestDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
@IsUUID()
|
||||||
|
deviceId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '0.1.0' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
appVersion!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeviceHeartbeatResponseDto {
|
||||||
|
@ApiProperty({ example: true })
|
||||||
|
ok!: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2026-05-24T20:00:00.000Z' })
|
||||||
|
serverTime!: string;
|
||||||
|
}
|
||||||
12
backend/src/modules/devices/devices.module.ts
Normal file
12
backend/src/modules/devices/devices.module.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||||
|
import { DevicesController } from './devices.controller';
|
||||||
|
import { DevicesService } from './devices.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
controllers: [DevicesController],
|
||||||
|
providers: [DevicesService],
|
||||||
|
exports: [DevicesService],
|
||||||
|
})
|
||||||
|
export class DevicesModule {}
|
||||||
64
backend/src/modules/devices/devices.service.ts
Normal file
64
backend/src/modules/devices/devices.service.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
|
import {
|
||||||
|
DeviceHeartbeatRequestDto,
|
||||||
|
DeviceHeartbeatResponseDto,
|
||||||
|
RegisterDeviceRequestDto,
|
||||||
|
RegisterDeviceResponseDto,
|
||||||
|
} from './devices.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DevicesService {
|
||||||
|
constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
|
async register(
|
||||||
|
body: RegisterDeviceRequestDto,
|
||||||
|
): Promise<RegisterDeviceResponseDto> {
|
||||||
|
const bootstrapToken = randomBytes(24).toString('hex');
|
||||||
|
const installTokenHash = createHash('sha256')
|
||||||
|
.update(bootstrapToken)
|
||||||
|
.digest('hex');
|
||||||
|
|
||||||
|
const device = await this.prismaService.device.create({
|
||||||
|
data: {
|
||||||
|
platform: body.platform,
|
||||||
|
deviceName: body.deviceName,
|
||||||
|
appVersion: body.appVersion,
|
||||||
|
installTokenHash,
|
||||||
|
lastSeenAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
deviceId: device.id,
|
||||||
|
bootstrapToken,
|
||||||
|
serverTime: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async heartbeat(
|
||||||
|
body: DeviceHeartbeatRequestDto,
|
||||||
|
): Promise<DeviceHeartbeatResponseDto> {
|
||||||
|
const existing = await this.prismaService.device.findUnique({
|
||||||
|
where: { id: body.deviceId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('Device not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prismaService.device.update({
|
||||||
|
where: { id: body.deviceId },
|
||||||
|
data: {
|
||||||
|
appVersion: body.appVersion,
|
||||||
|
lastSeenAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
serverTime: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
19
backend/src/modules/health/health.controller.ts
Normal file
19
backend/src/modules/health/health.controller.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { HealthResponseDto } from './health.dto';
|
||||||
|
import { HealthService } from './health.service';
|
||||||
|
|
||||||
|
@ApiTags('health')
|
||||||
|
@Controller({
|
||||||
|
path: 'health',
|
||||||
|
version: '1',
|
||||||
|
})
|
||||||
|
export class HealthController {
|
||||||
|
constructor(private readonly healthService: HealthService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOkResponse({ type: HealthResponseDto })
|
||||||
|
async getHealth(): Promise<HealthResponseDto> {
|
||||||
|
return this.healthService.getHealth();
|
||||||
|
}
|
||||||
|
}
|
||||||
28
backend/src/modules/health/health.dto.ts
Normal file
28
backend/src/modules/health/health.dto.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
class HealthDependencyDto {
|
||||||
|
@ApiProperty({ example: 'up' })
|
||||||
|
status!: 'up' | 'down';
|
||||||
|
}
|
||||||
|
|
||||||
|
class StorageDependencyDto extends HealthDependencyDto {
|
||||||
|
@ApiProperty({ example: '/app/runtime/storage' })
|
||||||
|
root!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HealthResponseDto {
|
||||||
|
@ApiProperty({ example: 'velody-backend' })
|
||||||
|
service!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '0.1.0' })
|
||||||
|
version!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: HealthDependencyDto })
|
||||||
|
database!: HealthDependencyDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: StorageDependencyDto })
|
||||||
|
storage!: StorageDependencyDto;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2026-05-24T20:00:00.000Z' })
|
||||||
|
serverTime!: string;
|
||||||
|
}
|
||||||
12
backend/src/modules/health/health.module.ts
Normal file
12
backend/src/modules/health/health.module.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||||
|
import { StorageModule } from '../storage/storage.module';
|
||||||
|
import { HealthController } from './health.controller';
|
||||||
|
import { HealthService } from './health.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, StorageModule],
|
||||||
|
controllers: [HealthController],
|
||||||
|
providers: [HealthService],
|
||||||
|
})
|
||||||
|
export class HealthModule {}
|
||||||
44
backend/src/modules/health/health.service.ts
Normal file
44
backend/src/modules/health/health.service.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AppConfigService } from '../config/config.service';
|
||||||
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
|
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
||||||
|
import { HealthResponseDto } from './health.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HealthService {
|
||||||
|
constructor(
|
||||||
|
private readonly configService: AppConfigService,
|
||||||
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly storageService: LocalFilesystemStorageService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getHealth(): Promise<HealthResponseDto> {
|
||||||
|
let databaseStatus: 'up' | 'down' = 'up';
|
||||||
|
let storageStatus: 'up' | 'down' = 'up';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.prismaService.$queryRawUnsafe('SELECT 1');
|
||||||
|
} catch {
|
||||||
|
databaseStatus = 'down';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.storageService.checkReadiness();
|
||||||
|
} catch {
|
||||||
|
storageStatus = 'down';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
service: 'velody-backend',
|
||||||
|
version: this.configService.appVersion,
|
||||||
|
database: {
|
||||||
|
status: databaseStatus,
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
status: storageStatus,
|
||||||
|
root: this.storageService.root,
|
||||||
|
},
|
||||||
|
serverTime: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
8
backend/src/modules/library/library.module.ts
Normal file
8
backend/src/modules/library/library.module.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { LibraryService } from './library.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [LibraryService],
|
||||||
|
exports: [LibraryService],
|
||||||
|
})
|
||||||
|
export class LibraryModule {}
|
||||||
9
backend/src/modules/library/library.service.ts
Normal file
9
backend/src/modules/library/library.service.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { LibraryTrackDto } from '../sync/sync.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LibraryService {
|
||||||
|
async getBootstrapTracks(): Promise<LibraryTrackDto[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
11
backend/src/modules/storage/storage.module.ts
Normal file
11
backend/src/modules/storage/storage.module.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { AppConfigModule } from '../config/config.module';
|
||||||
|
import { LocalFilesystemStorageService } from './storage.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [AppConfigModule],
|
||||||
|
providers: [LocalFilesystemStorageService],
|
||||||
|
exports: [LocalFilesystemStorageService],
|
||||||
|
})
|
||||||
|
export class StorageModule {}
|
||||||
40
backend/src/modules/storage/storage.service.ts
Normal file
40
backend/src/modules/storage/storage.service.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { mkdir, access } from 'node:fs/promises';
|
||||||
|
import { constants } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { AppConfigService } from '../config/config.service';
|
||||||
|
|
||||||
|
export interface StorageStatus {
|
||||||
|
root: string;
|
||||||
|
writable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LocalFilesystemStorageService {
|
||||||
|
constructor(private readonly configService: AppConfigService) {}
|
||||||
|
|
||||||
|
get root(): string {
|
||||||
|
return this.configService.storageRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkReadiness(): Promise<StorageStatus> {
|
||||||
|
const paths = [
|
||||||
|
this.root,
|
||||||
|
join(this.root, 'incoming'),
|
||||||
|
join(this.root, 'quarantine'),
|
||||||
|
join(this.root, 'library', 'audio'),
|
||||||
|
join(this.root, 'library', 'artwork'),
|
||||||
|
join(this.root, 'temp'),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const path of paths) {
|
||||||
|
await mkdir(path, { recursive: true });
|
||||||
|
await access(path, constants.R_OK | constants.W_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
root: this.root,
|
||||||
|
writable: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
31
backend/src/modules/sync/sync.controller.ts
Normal file
31
backend/src/modules/sync/sync.controller.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
|
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
SyncBootstrapResponseDto,
|
||||||
|
SyncChangesQueryDto,
|
||||||
|
SyncChangesResponseDto,
|
||||||
|
} from './sync.dto';
|
||||||
|
import { SyncService } from './sync.service';
|
||||||
|
|
||||||
|
@ApiTags('sync')
|
||||||
|
@Controller({
|
||||||
|
path: 'sync',
|
||||||
|
version: '1',
|
||||||
|
})
|
||||||
|
export class SyncController {
|
||||||
|
constructor(private readonly syncService: SyncService) {}
|
||||||
|
|
||||||
|
@Get('bootstrap')
|
||||||
|
@ApiOkResponse({ type: SyncBootstrapResponseDto })
|
||||||
|
async bootstrap(): Promise<SyncBootstrapResponseDto> {
|
||||||
|
return this.syncService.bootstrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('changes')
|
||||||
|
@ApiOkResponse({ type: SyncChangesResponseDto })
|
||||||
|
async changes(
|
||||||
|
@Query() query: SyncChangesQueryDto,
|
||||||
|
): Promise<SyncChangesResponseDto> {
|
||||||
|
return this.syncService.changes(query.after ?? '0');
|
||||||
|
}
|
||||||
|
}
|
||||||
54
backend/src/modules/sync/sync.dto.ts
Normal file
54
backend/src/modules/sync/sync.dto.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsOptional, IsString, Matches } from 'class-validator';
|
||||||
|
|
||||||
|
export class LibraryTrackDto {
|
||||||
|
@ApiProperty({ format: 'uuid', required: false })
|
||||||
|
id?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Placeholder Track', required: false })
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Velody', required: false })
|
||||||
|
artist?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SyncEventDto {
|
||||||
|
@ApiProperty({ example: 'TRACK' })
|
||||||
|
entityType!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
entityId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'CREATED' })
|
||||||
|
action!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '0' })
|
||||||
|
eventId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SyncBootstrapResponseDto {
|
||||||
|
@ApiProperty({ example: '0' })
|
||||||
|
nextCursor!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [LibraryTrackDto] })
|
||||||
|
tracks!: LibraryTrackDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: [SyncEventDto] })
|
||||||
|
events!: SyncEventDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: [String] })
|
||||||
|
deletedTrackIds!: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2026-05-24T20:00:00.000Z' })
|
||||||
|
serverTime!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SyncChangesQueryDto {
|
||||||
|
@ApiProperty({ required: false, example: '0' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^\d+$/)
|
||||||
|
after?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SyncChangesResponseDto extends SyncBootstrapResponseDto {}
|
||||||
12
backend/src/modules/sync/sync.module.ts
Normal file
12
backend/src/modules/sync/sync.module.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||||
|
import { LibraryModule } from '../library/library.module';
|
||||||
|
import { SyncController } from './sync.controller';
|
||||||
|
import { SyncService } from './sync.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, LibraryModule],
|
||||||
|
controllers: [SyncController],
|
||||||
|
providers: [SyncService],
|
||||||
|
})
|
||||||
|
export class SyncModule {}
|
||||||
48
backend/src/modules/sync/sync.service.ts
Normal file
48
backend/src/modules/sync/sync.service.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
|
import { LibraryService } from '../library/library.service';
|
||||||
|
import { SyncBootstrapResponseDto, SyncChangesResponseDto } from './sync.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SyncService {
|
||||||
|
constructor(
|
||||||
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly libraryService: LibraryService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async bootstrap(): Promise<SyncBootstrapResponseDto> {
|
||||||
|
const latestCursor = await this.getLatestCursor();
|
||||||
|
|
||||||
|
return {
|
||||||
|
nextCursor: latestCursor,
|
||||||
|
tracks: await this.libraryService.getBootstrapTracks(),
|
||||||
|
events: [],
|
||||||
|
deletedTrackIds: [],
|
||||||
|
serverTime: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async changes(after: string): Promise<SyncChangesResponseDto> {
|
||||||
|
const latestCursor = await this.getLatestCursor();
|
||||||
|
const normalizedCursor =
|
||||||
|
BigInt(latestCursor) > BigInt(after) ? latestCursor : after;
|
||||||
|
|
||||||
|
return {
|
||||||
|
nextCursor: normalizedCursor,
|
||||||
|
tracks: [],
|
||||||
|
events: [],
|
||||||
|
deletedTrackIds: [],
|
||||||
|
serverTime: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getLatestCursor(): Promise<string> {
|
||||||
|
const latest = await this.prismaService.libraryEvent.findFirst({
|
||||||
|
orderBy: {
|
||||||
|
id: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return latest?.id.toString() ?? '0';
|
||||||
|
}
|
||||||
|
}
|
||||||
53
backend/src/modules/uploads/uploads.controller.ts
Normal file
53
backend/src/modules/uploads/uploads.controller.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
NotImplementedException,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiCreatedResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
UploadFinalizeResponseDto,
|
||||||
|
UploadPrepareRequestDto,
|
||||||
|
UploadPrepareResponseDto,
|
||||||
|
UploadSessionStatusResponseDto,
|
||||||
|
} from './uploads.dto';
|
||||||
|
import { UploadsService } from './uploads.service';
|
||||||
|
|
||||||
|
@ApiTags('uploads')
|
||||||
|
@Controller({
|
||||||
|
path: 'uploads',
|
||||||
|
version: '1',
|
||||||
|
})
|
||||||
|
export class UploadsController {
|
||||||
|
constructor(private readonly uploadsService: UploadsService) {}
|
||||||
|
|
||||||
|
@Post('prepare')
|
||||||
|
@ApiCreatedResponse({ type: UploadPrepareResponseDto })
|
||||||
|
async prepare(
|
||||||
|
@Body() body: UploadPrepareRequestDto,
|
||||||
|
): Promise<UploadPrepareResponseDto> {
|
||||||
|
return this.uploadsService.prepare(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':uploadId')
|
||||||
|
@ApiOkResponse({ type: UploadSessionStatusResponseDto })
|
||||||
|
async getStatus(
|
||||||
|
@Param('uploadId') uploadId: string,
|
||||||
|
): Promise<UploadSessionStatusResponseDto> {
|
||||||
|
return this.uploadsService.getStatus(uploadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':uploadId/finalize')
|
||||||
|
@ApiOperation({ summary: 'Reserved for the next milestone' })
|
||||||
|
@ApiOkResponse({ type: UploadFinalizeResponseDto })
|
||||||
|
async finalize(): Promise<UploadFinalizeResponseDto> {
|
||||||
|
throw new NotImplementedException('Upload finalization is not implemented yet.');
|
||||||
|
}
|
||||||
|
}
|
||||||
62
backend/src/modules/uploads/uploads.dto.ts
Normal file
62
backend/src/modules/uploads/uploads.dto.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { UploadSessionStatus } from '@prisma/client';
|
||||||
|
import { IsInt, IsString, IsUUID, Matches, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class UploadPrepareRequestDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
@IsUUID()
|
||||||
|
deviceId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example:
|
||||||
|
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||||
|
})
|
||||||
|
@Matches(/^[a-f0-9]{64}$/)
|
||||||
|
sha256!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'track.mp3' })
|
||||||
|
@IsString()
|
||||||
|
originalFilename!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 10485760 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(Number.MAX_SAFE_INTEGER)
|
||||||
|
sizeBytes!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UploadPrepareResponseDto {
|
||||||
|
@ApiProperty({ enum: ['exists', 'upload_required'] })
|
||||||
|
status!: 'exists' | 'upload_required';
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, format: 'uuid' })
|
||||||
|
uploadId?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, example: 0 })
|
||||||
|
nextOffset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UploadSessionStatusResponseDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
uploadId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: UploadSessionStatus })
|
||||||
|
status!: UploadSessionStatus;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 0 })
|
||||||
|
receivedBytes!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 10485760 })
|
||||||
|
expectedSizeBytes!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 0 })
|
||||||
|
nextOffset!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UploadFinalizeResponseDto {
|
||||||
|
@ApiProperty({ example: 501 })
|
||||||
|
statusCode!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Upload finalization is not implemented yet.' })
|
||||||
|
message!: string;
|
||||||
|
}
|
||||||
11
backend/src/modules/uploads/uploads.module.ts
Normal file
11
backend/src/modules/uploads/uploads.module.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||||
|
import { UploadsController } from './uploads.controller';
|
||||||
|
import { UploadsService } from './uploads.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
controllers: [UploadsController],
|
||||||
|
providers: [UploadsService],
|
||||||
|
})
|
||||||
|
export class UploadsModule {}
|
||||||
80
backend/src/modules/uploads/uploads.service.ts
Normal file
80
backend/src/modules/uploads/uploads.service.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
UnprocessableEntityException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
|
import { AppConfigService } from '../config/config.service';
|
||||||
|
import { UploadPrepareRequestDto, UploadPrepareResponseDto, UploadSessionStatusResponseDto } from './uploads.dto';
|
||||||
|
import { UploadSessionStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UploadsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly configService: AppConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async prepare(
|
||||||
|
body: UploadPrepareRequestDto,
|
||||||
|
): Promise<UploadPrepareResponseDto> {
|
||||||
|
if (body.sizeBytes > this.configService.maxUploadSizeBytes) {
|
||||||
|
throw new UnprocessableEntityException('Upload exceeds the configured maximum size.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const device = await this.prismaService.device.findUnique({
|
||||||
|
where: { id: body.deviceId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
throw new NotFoundException('Device not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingAsset = await this.prismaService.audioAsset.findUnique({
|
||||||
|
where: { sha256: body.sha256 },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingAsset) {
|
||||||
|
return {
|
||||||
|
status: 'exists',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadSession = await this.prismaService.uploadSession.create({
|
||||||
|
data: {
|
||||||
|
deviceId: body.deviceId,
|
||||||
|
expectedSha256: body.sha256,
|
||||||
|
expectedSizeBytes: BigInt(body.sizeBytes),
|
||||||
|
receivedBytes: BigInt(0),
|
||||||
|
tempStoragePath: join('incoming', `${body.sha256}.part`),
|
||||||
|
status: UploadSessionStatus.READY_TO_UPLOAD,
|
||||||
|
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'upload_required',
|
||||||
|
uploadId: uploadSession.id,
|
||||||
|
nextOffset: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStatus(uploadId: string): Promise<UploadSessionStatusResponseDto> {
|
||||||
|
const uploadSession = await this.prismaService.uploadSession.findUnique({
|
||||||
|
where: { id: uploadId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!uploadSession) {
|
||||||
|
throw new NotFoundException('Upload session not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
uploadId: uploadSession.id,
|
||||||
|
status: uploadSession.status,
|
||||||
|
receivedBytes: uploadSession.receivedBytes.toString(),
|
||||||
|
expectedSizeBytes: uploadSession.expectedSizeBytes.toString(),
|
||||||
|
nextOffset: uploadSession.receivedBytes.toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
146
backend/test/e2e/app.e2e-spec.ts
Normal file
146
backend/test/e2e/app.e2e-spec.ts
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { AppModule } from '../../src/app.module';
|
||||||
|
import { AppConfigService } from '../../src/modules/config/config.service';
|
||||||
|
import { DevicesController } from '../../src/modules/devices/devices.controller';
|
||||||
|
import { HealthController } from '../../src/modules/health/health.controller';
|
||||||
|
import { SyncController } from '../../src/modules/sync/sync.controller';
|
||||||
|
import { LocalFilesystemStorageService } from '../../src/modules/storage/storage.service';
|
||||||
|
import { PrismaService } from '../../src/infrastructure/database/prisma.service';
|
||||||
|
|
||||||
|
function createPrismaMock() {
|
||||||
|
const devices = new Map<string, any>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
$queryRawUnsafe: jest.fn().mockResolvedValue([{ '?column?': 1 }]),
|
||||||
|
device: {
|
||||||
|
create: jest.fn().mockImplementation(async ({ data }) => {
|
||||||
|
const id = randomUUID();
|
||||||
|
const record = { id, ...data };
|
||||||
|
devices.set(id, record);
|
||||||
|
return record;
|
||||||
|
}),
|
||||||
|
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||||
|
return devices.get(where.id) ?? null;
|
||||||
|
}),
|
||||||
|
update: jest.fn().mockImplementation(async ({ where, data }) => {
|
||||||
|
const current = devices.get(where.id);
|
||||||
|
const updated = { ...current, ...data };
|
||||||
|
devices.set(where.id, updated);
|
||||||
|
return updated;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
audioAsset: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue(null),
|
||||||
|
},
|
||||||
|
uploadSession: {
|
||||||
|
create: jest.fn().mockImplementation(async ({ data }) => {
|
||||||
|
return {
|
||||||
|
id: randomUUID(),
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
findUnique: jest.fn().mockResolvedValue(null),
|
||||||
|
},
|
||||||
|
libraryEvent: {
|
||||||
|
findFirst: jest.fn().mockResolvedValue(null),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Velody API wiring (e2e)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let healthController: HealthController;
|
||||||
|
let devicesController: DevicesController;
|
||||||
|
let syncController: SyncController;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const prismaMock = createPrismaMock();
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
})
|
||||||
|
.overrideProvider(AppConfigService)
|
||||||
|
.useValue({
|
||||||
|
appVersion: '0.1.0',
|
||||||
|
maxUploadSizeBytes: 1024 * 1024 * 1024,
|
||||||
|
storageRoot: '/tmp/velody-storage',
|
||||||
|
})
|
||||||
|
.overrideProvider(LocalFilesystemStorageService)
|
||||||
|
.useValue({
|
||||||
|
root: '/tmp/velody-storage',
|
||||||
|
checkReadiness: jest.fn().mockResolvedValue({
|
||||||
|
root: '/tmp/velody-storage',
|
||||||
|
writable: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.overrideProvider(PrismaService)
|
||||||
|
.useValue(prismaMock)
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
|
app.enableVersioning({ type: VersioningType.URI });
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
healthController = moduleRef.get(HealthController);
|
||||||
|
devicesController = moduleRef.get(DevicesController);
|
||||||
|
syncController = moduleRef.get(SyncController);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (app) {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns health information', async () => {
|
||||||
|
const response = await healthController.getHealth();
|
||||||
|
|
||||||
|
expect(response.database.status).toBe('up');
|
||||||
|
expect(response.storage.status).toBe('up');
|
||||||
|
expect(response.version).toBe('0.1.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers a device', async () => {
|
||||||
|
const response = await devicesController.register({
|
||||||
|
platform: 'MACOS',
|
||||||
|
deviceName: 'Diya MacBook Pro',
|
||||||
|
appVersion: '0.1.0',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.deviceId).toBeDefined();
|
||||||
|
expect(response.bootstrapToken).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts device heartbeat', async () => {
|
||||||
|
const registerResponse = await devicesController.register({
|
||||||
|
platform: 'IPHONE',
|
||||||
|
deviceName: 'Diya iPhone',
|
||||||
|
appVersion: '0.1.0',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await devicesController.heartbeat({
|
||||||
|
deviceId: registerResponse.deviceId,
|
||||||
|
appVersion: '0.1.1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty sync bootstrap and changes payloads', async () => {
|
||||||
|
const bootstrapResponse = await syncController.bootstrap();
|
||||||
|
const changesResponse = await syncController.changes({ after: '0' });
|
||||||
|
|
||||||
|
expect(bootstrapResponse.tracks).toEqual([]);
|
||||||
|
expect(changesResponse.events).toEqual([]);
|
||||||
|
expect(changesResponse.nextCursor).toBe('0');
|
||||||
|
});
|
||||||
|
});
|
||||||
10
backend/test/jest-e2e.json
Normal file
10
backend/test/jest-e2e.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
|
"rootDir": "..",
|
||||||
|
"testRegex": ".*\\.e2e-spec\\.ts$",
|
||||||
|
"setupFiles": ["<rootDir>/test/setup-env.ts"],
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"testEnvironment": "node"
|
||||||
|
}
|
||||||
15
backend/test/setup-env.ts
Normal file
15
backend/test/setup-env.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
|
||||||
|
process.env.NODE_ENV = process.env.NODE_ENV ?? 'test';
|
||||||
|
process.env.PORT = process.env.PORT ?? '3001';
|
||||||
|
process.env.DATABASE_URL =
|
||||||
|
process.env.DATABASE_URL ??
|
||||||
|
'postgresql://velody:velody@localhost:5432/velody?schema=public';
|
||||||
|
process.env.STORAGE_ROOT =
|
||||||
|
process.env.STORAGE_ROOT ?? '/tmp/velody-storage';
|
||||||
|
process.env.PUBLIC_BASE_URL =
|
||||||
|
process.env.PUBLIC_BASE_URL ?? 'http://localhost:3000';
|
||||||
|
process.env.DEVICE_BOOTSTRAP_SECRET =
|
||||||
|
process.env.DEVICE_BOOTSTRAP_SECRET ?? 'test-secret';
|
||||||
|
process.env.MAX_UPLOAD_SIZE_BYTES =
|
||||||
|
process.env.MAX_UPLOAD_SIZE_BYTES ?? '1073741824';
|
||||||
12
backend/tsconfig.build.json
Normal file
12
backend/tsconfig.build.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"declaration": false,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"exclude": [
|
||||||
|
"test",
|
||||||
|
"scripts",
|
||||||
|
"**/*.spec.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
32
backend/tsconfig.json
Normal file
32
backend/tsconfig.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": false,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"types": [
|
||||||
|
"node",
|
||||||
|
"jest"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts",
|
||||||
|
"test/**/*.ts",
|
||||||
|
"scripts/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"dist",
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
1761
docs/PROJECT_ENVIRONMENT_ARCHITECTURE.md
Normal file
1761
docs/PROJECT_ENVIRONMENT_ARCHITECTURE.md
Normal file
File diff suppressed because it is too large
Load Diff
33
infra/docker/compose.local.yml
Normal file
33
infra/docker/compose.local.yml
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- ../docker/env/postgres.env.example
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ../../
|
||||||
|
dockerfile: backend/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- ../docker/env/backend.env.example
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ../../runtime:/app/runtime
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
7
infra/docker/env/backend.env.example
vendored
Normal file
7
infra/docker/env/backend.env.example
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
NODE_ENV=development
|
||||||
|
PORT=3000
|
||||||
|
DATABASE_URL=postgresql://velody:velody@postgres:5432/velody?schema=public
|
||||||
|
STORAGE_ROOT=/app/runtime/storage
|
||||||
|
PUBLIC_BASE_URL=http://localhost:3000
|
||||||
|
DEVICE_BOOTSTRAP_SECRET=replace-me
|
||||||
|
MAX_UPLOAD_SIZE_BYTES=524288000
|
||||||
3
infra/docker/env/postgres.env.example
vendored
Normal file
3
infra/docker/env/postgres.env.example
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
POSTGRES_DB=velody
|
||||||
|
POSTGRES_USER=velody
|
||||||
|
POSTGRES_PASSWORD=velody
|
||||||
13
infra/nginx/music.diyaa.de.conf
Normal file
13
infra/nginx/music.diyaa.de.conf
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name music.diyaa.de;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
5
infra/scripts/backup-db.sh
Executable file
5
infra/scripts/backup-db.sh
Executable file
@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "Database backup script placeholder."
|
||||||
5
infra/scripts/backup-storage.sh
Executable file
5
infra/scripts/backup-storage.sh
Executable file
@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "Storage backup script placeholder."
|
||||||
5
infra/scripts/deploy.sh
Executable file
5
infra/scripts/deploy.sh
Executable file
@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "Deployment script placeholder for a future VPS rollout."
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.5/Combine.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
dependencies:
|
||||||
|
- mtime: 1777405596000000000
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.5/Combine.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
size: 491412
|
||||||
|
- mtime: 1776558630000000000
|
||||||
|
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 2193647
|
||||||
|
sdk_relative: true
|
||||||
|
- mtime: 1772775407000000000
|
||||||
|
path: 'usr/include/_DarwinFoundation2.apinotes'
|
||||||
|
size: 1145
|
||||||
|
sdk_relative: true
|
||||||
|
- mtime: 1776561783000000000
|
||||||
|
path: 'usr/lib/swift/_DarwinFoundation1.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 18805
|
||||||
|
sdk_relative: true
|
||||||
|
- mtime: 1776561789000000000
|
||||||
|
path: 'usr/lib/swift/_DarwinFoundation2.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 2677
|
||||||
|
sdk_relative: true
|
||||||
|
- mtime: 1776561795000000000
|
||||||
|
path: 'usr/lib/swift/_DarwinFoundation3.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 1573
|
||||||
|
sdk_relative: true
|
||||||
|
- mtime: 1776559222000000000
|
||||||
|
path: 'usr/lib/swift/_Builtin_float.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 4363
|
||||||
|
sdk_relative: true
|
||||||
|
- mtime: 1776561805000000000
|
||||||
|
path: 'usr/lib/swift/Darwin.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 19539
|
||||||
|
sdk_relative: true
|
||||||
|
- mtime: 1776559177000000000
|
||||||
|
path: 'usr/lib/swift/_Concurrency.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 280495
|
||||||
|
sdk_relative: true
|
||||||
|
- mtime: 1776562794000000000
|
||||||
|
path: 'usr/lib/swift/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 22987
|
||||||
|
sdk_relative: true
|
||||||
|
- mtime: 1776563522000000000
|
||||||
|
path: 'System/Library/Frameworks/Combine.framework/Modules/Combine.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 167873
|
||||||
|
sdk_relative: true
|
||||||
|
version: 1
|
||||||
|
...
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user