version 1.0.0

This commit is contained in:
diyaa 2026-05-24 20:53:42 +02:00
commit 4b8701e55b
6577 changed files with 25019 additions and 0 deletions

118
.gitignore vendored Normal file
View 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
View 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

View 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 */;
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Velody.xcodeproj">
</FileRef>
</Workspace>

View 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
)
}
}

View 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()
}
}
}

View 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
}
}
}

View File

@ -0,0 +1,10 @@
import SwiftUI
@main
struct VelodyMacApp: App {
var body: some Scene {
WindowGroup {
MacLibraryView()
}
}
}

View File

@ -0,0 +1,10 @@
import SwiftUI
@main
struct VelodyiPhoneApp: App {
var body: some Scene {
WindowGroup {
iPhoneLibraryView()
}
}
}

View 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()
}
}
}

View 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
View 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
View File

@ -0,0 +1,4 @@
node_modules
dist
coverage
.env

7
backend/.env.example Normal file
View 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
View 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
View 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",
};

View 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

File diff suppressed because it is too large Load Diff

44
backend/package.json Normal file
View 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"
}
}

View File

@ -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;

View File

@ -0,0 +1,2 @@
# Please do not edit this file manually
provider = "postgresql"

View 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
}

View 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();

View 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
View 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 {}

View 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 {}

View 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
View 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();

View 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 {}

View 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;
}
}

View 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/);
});
});

View 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>;

View 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);
}
}

View 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;
}

View 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 {}

View 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(),
};
}
}

View 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();
}
}

View 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;
}

View 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 {}

View 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(),
};
}
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { LibraryService } from './library.service';
@Module({
providers: [LibraryService],
exports: [LibraryService],
})
export class LibraryModule {}

View 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 [];
}
}

View 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 {}

View 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,
};
}
}

View 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');
}
}

View 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 {}

View 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 {}

View 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';
}
}

View 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.');
}
}

View 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;
}

View 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 {}

View 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(),
};
}
}

View 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');
});
});

View 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
View 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';

View File

@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": false,
"sourceMap": true
},
"exclude": [
"test",
"scripts",
"**/*.spec.ts"
]
}

32
backend/tsconfig.json Normal file
View 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"
]
}

File diff suppressed because it is too large Load Diff

View 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
View 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
View File

@ -0,0 +1,3 @@
POSTGRES_DB=velody
POSTGRES_USER=velody
POSTGRES_PASSWORD=velody

View 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
View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
echo "Database backup script placeholder."

View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
echo "Storage backup script placeholder."

5
infra/scripts/deploy.sh Executable file
View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
echo "Deployment script placeholder for a future VPS rollout."

View File

@ -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