new release management

This commit is contained in:
2026-01-27 20:01:25 +01:00
parent 27960741a3
commit ea78a83d78
11 changed files with 176 additions and 371 deletions
+2 -2
View File
@@ -75,7 +75,7 @@
- `parseMeta()` - Metadatok feldolgozása (de `FileRepository.ReadMetaFromFile()` segítségével)
#### ✅ 3.2 Environment Variables - Centralizált konfiguráció
- `GAMES_DIR` és `GAMES_DIR` továbbra is `os.Getenv()`-el hívódnak
- `FILE_CONTAINER_PATH` és `FILE_CONTAINER_PATH` továbbra is `os.Getenv()`-el hívódnak
- MEGLÉPÉS: Az env vars a Domain inicializációban továbbra is szétszórva vannak
- TODO: Config struct még nem készült (de nem kritikus)
@@ -100,7 +100,7 @@
```
#### ✅ 3.6 Erőforrás nevek megtisztítása
- `GAMES_DIR` → `GAMES_DIR` (nem "game" szó)
- `FILE_CONTAINER_PATH` → `FILE_CONTAINER_PATH` (nem "game" szó)
- Összes referencia frissítve
---
+1 -1
View File
@@ -97,7 +97,7 @@ services:
- DB_PORT=3306
- DB_NAME=softwares
- UPDATE_SECRET=${UPDATE_SECRET}
- GAMES_DIR=/softwares
- FILE_CONTAINER_PATH=/softwares
depends_on:
mysql:
condition: service_healthy
+8 -7
View File
@@ -5,12 +5,13 @@ import (
)
type Domain struct {
SoftwareRepository SoftwareRepositoryInterface
ReleaseRepository ReleaseRepositoryInterface
FileRepository FileRepositoryInterface
DownloadService DownloadServiceInterface
SoftwareUpdaterService SoftwareUpdaterServiceInterface
SoftwareService SoftwareServiceInterface
SoftwareRepository SoftwareRepositoryInterface
ReleaseRepository ReleaseRepositoryInterface
FileRepository FileRepositoryInterface
DownloadService DownloadServiceInterface
SoftwareUpdaterService SoftwareUpdaterServiceInterface
SoftwareService SoftwareServiceInterface
FileService FileServiceInterface
}
func NewDomain() Domain {
@@ -22,7 +23,7 @@ func NewDomain() Domain {
fileRepository := NewFileRepository()
softwareService := NewSoftwareService(softwareRepository)
tic80Updater := NewSoftwareUpdaterTIC80Service(
softwareRepository,
releaseRepository,
+5 -5
View File
@@ -4,9 +4,9 @@ import "gorm.io/gorm"
type Release struct {
gorm.Model
SoftwareID uint `gorm:"index" json:"software_id"`
Version string `gorm:"size:64" json:"version"`
CartridgePath string `gorm:"size:255" json:"-"`
SourcePath string `gorm:"size:255" json:"-"`
WebPlayable bool `gorm:"default:false" json:"web_playable"`
SoftwareID uint `gorm:"index" json:"software_id"`
Version string `gorm:"size:64" json:"version"`
CartridgePath string `gorm:"size:255" json:"-"`
SourcePath string `gorm:"size:255" json:"-"`
HTMLFolderPath string `gorm:"size:255" json:"-"`
}
+67 -146
View File
@@ -2,7 +2,6 @@ package domain
import (
"archive/zip"
"bufio"
"fmt"
"io"
"os"
@@ -11,185 +10,107 @@ import (
)
type FileRepositoryInterface interface {
FileExists(fileName, basePath string) bool
CreateDir(dirPath string) error
DeleteFile(fileName, basePath string) error
MoveFile(srcFileName, destPath, basePath string) error
UnzipHTMLContent(zipFilePath, softwareName, version, basePath string) error
GetSoftwareDir(softwareName, basePath string) string
GetSoftwareVersionDir(softwareName, version, basePath string) string
GetFileInSoftwareVersionDir(softwareName, version, fileName, basePath string) string
GetHTMLContentDir(softwareName, version, basePath string) string
GetCartridgePath(softwareName, version, basePath string) string
GetSourcePath(softwareName, version, basePath string) string
ReadMetaFromFile(filePath string, basePath string) (map[string]string, error)
GetPath(path string) string
FileExists(path string) bool
CreateDir(path string) error
DeleteFile(path string) error
MoveFile(srcPath, destPath string) error
UnzipFile(path, destPath string) error
}
type FileRepository struct{}
type FileRepository struct {
fileContainerPath string
}
func NewFileRepository() *FileRepository {
return &FileRepository{}
fileContainerPath, _ := os.LookupEnv("FILE_CONTAINER_PATH")
return &FileRepository{
fileContainerPath: fileContainerPath,
}
}
func (r *FileRepository) FileExists(fileName, basePath string) bool {
var filePath string
if filepath.IsAbs(fileName) {
filePath = fileName
} else if basePath != "" {
filePath = filepath.Join(basePath, fileName)
} else {
filePath = fileName // Assume it's relative to current working dir or absolute
}
func (fr *FileRepository) GetPath(path string) string {
return filepath.Join(fr.fileContainerPath, path)
}
_, err := os.Stat(filePath)
func (fr *FileRepository) FileExists(path string) bool {
fullPath := fr.GetPath(path)
_, err := os.Stat(fullPath)
return err == nil
}
func (r *FileRepository) CreateDir(dirPath string) error {
return os.MkdirAll(dirPath, os.ModePerm)
func (fr *FileRepository) CreateDir(path string) error {
fullPath := fr.GetPath(path)
return os.MkdirAll(fullPath, 0755)
}
func (r *FileRepository) DeleteFile(fileName, basePath string) error {
var filePath string
if filepath.IsAbs(fileName) {
filePath = fileName
} else if basePath != "" {
filePath = filepath.Join(basePath, fileName)
} else {
filePath = fileName
func (fr *FileRepository) DeleteFile(path string) error {
fullPath := fr.GetPath(path)
return os.RemoveAll(fullPath)
}
func (fr *FileRepository) MoveFile(srcPath, destPath string) error {
fullSrcPath := fr.GetPath(srcPath)
fullDestPath := fr.GetPath(destPath)
destDir := filepath.Dir(fullDestPath)
if err := os.MkdirAll(destDir, 0755); err != nil {
return fmt.Errorf("failed to create destination directory: %w", err)
}
return os.Remove(filePath)
return os.Rename(fullSrcPath, fullDestPath)
}
func (r *FileRepository) MoveFile(srcFileName, destPath, basePath string) error {
var srcPath string
if filepath.IsAbs(srcFileName) {
srcPath = srcFileName
} else if basePath != "" {
srcPath = filepath.Join(basePath, srcFileName)
} else {
srcPath = srcFileName
}
return os.Rename(srcPath, destPath)
}
func (fr *FileRepository) UnzipFile(path, destPath string) error {
fullPath := fr.GetPath(path)
fullDestPath := fr.GetPath(destPath)
func (r *FileRepository) UnzipHTMLContent(zipFilePath, softwareName, version, basePath string) error {
destDir := r.GetHTMLContentDir(softwareName, version, basePath)
fmt.Printf("FileRepository: Unzipping %s to %s\n", zipFilePath, destDir)
reader, err := zip.OpenReader(zipFilePath)
reader, err := zip.OpenReader(fullPath)
if err != nil {
return err
return fmt.Errorf("failed to open zip file: %w", err)
}
defer reader.Close()
destDir, err = filepath.Abs(destDir)
if err != nil {
return err
if err := os.MkdirAll(fullDestPath, 0755); err != nil {
return fmt.Errorf("failed to create destination directory: %w", err)
}
for _, f := range reader.File {
fpath := filepath.Join(destDir, f.Name)
if !strings.HasPrefix(fpath, destDir) {
return fmt.Errorf("%s: illegal file path", fpath)
}
if f.FileInfo().IsDir() {
os.MkdirAll(fpath, os.ModePerm)
continue
}
if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
return err
}
outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
outFile.Close()
return err
}
rc, err := f.Open()
if err != nil {
outFile.Close()
return err
}
_, err = io.Copy(outFile, rc)
outFile.Close()
rc.Close()
if err != nil {
return err
for _, file := range reader.File {
if err := extractZipFile(file, fullDestPath); err != nil {
return fmt.Errorf("failed to extract %s: %w", file.Name, err)
}
}
return nil
}
func (r *FileRepository) GetSoftwareDir(softwareName, basePath string) string {
return filepath.Join(basePath, softwareName)
}
func extractZipFile(file *zip.File, destPath string) error {
filePath := filepath.Join(destPath, file.Name)
func (r *FileRepository) GetSoftwareVersionDir(softwareName, version, basePath string) string {
return filepath.Join(r.GetSoftwareDir(softwareName, basePath), version)
}
func (r *FileRepository) GetFileInSoftwareVersionDir(softwareName, version, fileName, basePath string) string {
return filepath.Join(r.GetSoftwareVersionDir(softwareName, version, basePath), fileName)
}
func (r *FileRepository) GetHTMLContentDir(softwareName, version, basePath string) string {
return filepath.Join(r.GetSoftwareVersionDir(softwareName, version, basePath), "html")
}
func (r *FileRepository) GetCartridgePath(softwareName, version, basePath string) string {
return filepath.Join(r.GetSoftwareVersionDir(softwareName, version, basePath), fmt.Sprintf("%s.tic", softwareName))
}
func (r *FileRepository) GetSourcePath(softwareName, version, basePath string) string {
return filepath.Join(r.GetSoftwareVersionDir(softwareName, version, basePath), fmt.Sprintf("%s.lua", softwareName))
}
func (r *FileRepository) ReadMetaFromFile(filePath string, basePath string) (map[string]string, error) {
var fullPath string
if filepath.IsAbs(filePath) {
fullPath = filePath
} else if basePath != "" {
fullPath = filepath.Join(basePath, filePath)
} else {
fullPath = filePath
if !strings.HasPrefix(filePath, filepath.Clean(destPath)+string(os.PathSeparator)) {
return fmt.Errorf("invalid file path: %s", file.Name)
}
file, err := os.Open(fullPath)
if file.FileInfo().IsDir() {
return os.MkdirAll(filePath, file.Mode())
}
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
return err
}
srcFile, err := file.Open()
if err != nil {
return nil, err
return err
}
defer file.Close()
defer srcFile.Close()
metaData := make(map[string]string)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "--") {
parts := strings.SplitN(strings.TrimPrefix(line, "--"), ":", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
metaData[strings.ToLower(key)] = val
} else {
break
}
destFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode())
if err != nil {
return err
}
defer destFile.Close()
if err := scanner.Err(); err != nil {
return nil, err
}
return metaData, nil
_, err = io.Copy(destFile, srcFile)
return err
}
+19
View File
@@ -0,0 +1,19 @@
package domain
type FileServiceInterface interface {
GetPath(path string) string
}
type FileService struct {
FileRepository FileRepositoryInterface
}
func NewFileService() *FileService {
return &FileService{
FileRepository: NewFileRepository(),
}
}
func (s *FileService) GetPath(path string) string {
return s.FileRepository.GetPath(path)
}
+63 -106
View File
@@ -1,8 +1,10 @@
package domain
import (
"bufio"
"fmt"
"os"
"strings"
)
type SoftwareUpdaterTIC80ServiceInterface interface {
@@ -29,131 +31,86 @@ func NewSoftwareUpdaterTIC80Service(
func (s *SoftwareUpdaterTIC80Service) Update(name, version string) error {
fmt.Printf("TIC80 Updater: Starting update for name: %s, version: %s\n", name, version)
contentsPath, _ := os.LookupEnv("GAMES_DIR")
if err := s.handleHTMLContent(name, version, contentsPath); err == nil {
fmt.Printf("TIC80 Updater: Successfully processed HTML content: %s\n", name)
}
cartridgePath := s.fileRepository.GetPath(name + "-" + version + ".tic")
sourcePath := s.fileRepository.GetPath(name + "-" + version + ".lua")
zipPath := s.fileRepository.GetPath(name + "-" + version + ".html.zip")
htmlFolderPath := s.fileRepository.GetPath(name + "-" + version)
if err := s.handleLuaCartridge(name, version, contentsPath); err != nil {
s.fileRepository.CreateDir(htmlFolderPath)
s.fileRepository.UnzipFile(zipPath, htmlFolderPath)
var err error
metaData, err := s.GetMetadata(sourcePath)
if err != nil {
return err
}
software := s.BuildSoftware(metaData)
err = s.softwareRepository.UpdateOrCreate(software)
if err != nil {
return err
}
release := Release{
SoftwareID: software.ID,
Version: version,
CartridgePath: cartridgePath,
SourcePath: sourcePath,
HTMLFolderPath: htmlFolderPath,
}
s.releaseRepository.Create(&release)
fmt.Printf("TIC80 Updater: Successfully processed Lua cartridge: %s\n", name)
return nil
}
func (s *SoftwareUpdaterTIC80Service) handleHTMLContent(name, version, contentsPath string) error {
zipFileName := fmt.Sprintf("%s.html.zip", name)
// The zip file is now in the versioned folder
zipFilePathInVersionDir := s.fileRepository.GetFileInSoftwareVersionDir(name, version, zipFileName, contentsPath)
if err := s.fileRepository.UnzipHTMLContent(zipFilePathInVersionDir, name, version, contentsPath); err != nil {
return err
func (s *SoftwareUpdaterTIC80Service) BuildSoftware(metadata map[string]string) *Software {
software := &Software{
Name: metadata["name"],
Title: metadata["title"],
Author: metadata["author"],
Desc: metadata["desc"],
Site: metadata["site"],
License: metadata["license"],
Platform: "tic80",
}
return s.fileRepository.DeleteFile(zipFilePathInVersionDir, contentsPath)
return software
}
func (s *SoftwareUpdaterTIC80Service) handleLuaCartridge(name, version, contentsPath string) error {
// The lua and tic files are now in the versioned folder.
luaFileName := s.fileRepository.GetFileInSoftwareVersionDir(name, version, fmt.Sprintf("%s.lua", name), contentsPath)
cartridgeFileName := s.fileRepository.GetFileInSoftwareVersionDir(name, version, fmt.Sprintf("%s.tic", name), contentsPath)
func (s *SoftwareUpdaterTIC80Service) GetMetadata(sourcePath string) (map[string]string, error) {
if !s.fileRepository.FileExists(luaFileName, "") { // basePath is already included in luaFileName
return fmt.Errorf("no recognizable content file found for '%s' in '%s'", name, contentsPath)
}
if !s.fileRepository.FileExists(cartridgeFileName, "") { // basePath is already included in cartridgeFileName
return fmt.Errorf("missing cartridge file '%s' for '%s'", cartridgeFileName, luaFileName)
}
software, parsedVersion, err := s.parseMeta(luaFileName, name, contentsPath)
file, err := os.Open(sourcePath)
if err != nil {
return err
return nil, err
}
defer file.Close()
// Use the version from the webhook for consistency
if version != parsedVersion {
fmt.Printf("TIC80 Updater: Warning - parsed version '%s' from Lua file differs from provided version '%s'\n", parsedVersion, version)
}
if version == "" {
return fmt.Errorf("missing version info (webhook or parsed) for '%s'", luaFileName)
}
metaData := make(map[string]string)
scanner := bufio.NewScanner(file)
if err := s.softwareRepository.UpdateOrCreate(&software); err != nil {
return err
}
if err := s.moveCartridgeFiles(name, software, version, luaFileName, cartridgeFileName, contentsPath); err != nil {
return err
}
release := &Release{
SoftwareID: software.ID,
Version: version,
CartridgePath: s.fileRepository.GetCartridgePath(software.Name, version, contentsPath),
SourcePath: s.fileRepository.GetSourcePath(software.Name, version, contentsPath),
WebPlayable: true,
}
return s.releaseRepository.Create(release)
}
func (s *SoftwareUpdaterTIC80Service) moveCartridgeFiles(name string, software Software, version, luaSrcPath, cartridgeSrcPath, contentsPath string) error {
softwareVersionDir := s.fileRepository.GetSoftwareVersionDir(software.Name, version, contentsPath)
fmt.Printf("TIC80 Updater: Creating software version directory: %s\n", softwareVersionDir)
if err := s.fileRepository.CreateDir(softwareVersionDir); err != nil {
return err
}
newCartridgePath := s.fileRepository.GetCartridgePath(software.Name, version, contentsPath)
newSourcePath := s.fileRepository.GetSourcePath(software.Name, version, contentsPath)
fmt.Printf("TIC80 Updater: Moving cartridge from %s to %s\n", cartridgeSrcPath, newCartridgePath)
if err := s.fileRepository.MoveFile(cartridgeSrcPath, newCartridgePath, ""); err != nil { // srcPath includes basePath
return err
}
fmt.Printf("TIC80 Updater: Moving Lua source from %s to %s\n", luaSrcPath, newSourcePath)
if err := s.fileRepository.MoveFile(luaSrcPath, newSourcePath, ""); err != nil { // srcPath includes basePath
return err
}
return nil
}
func (s *SoftwareUpdaterTIC80Service) parseMeta(luaFileName, name, contentsPath string) (Software, string, error) {
var software Software
var version string
metaData, err := s.fileRepository.ReadMetaFromFile(luaFileName, "") // luaFileName already contains basePath
if err != nil {
return software, "", err
}
software.Name = name
software.Platform = "tic80"
for key, val := range metaData {
switch key {
case "title":
software.Title = val
case "author":
software.Author = val
case "desc":
software.Desc = val
case "site":
software.Site = val
case "license":
software.License = val
case "version":
version = val
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "--") {
parts := strings.SplitN(strings.TrimPrefix(line, "--"), ":", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
metaData[strings.ToLower(key)] = val
} else {
break
}
}
return software, version, nil
if err := scanner.Err(); err != nil {
return nil, err
}
return metaData, nil
}
+1 -1
View File
@@ -33,7 +33,7 @@ func (c *DownloadController) serve(w http.ResponseWriter, r *http.Request, relea
return
}
absContentsDir, err := filepath.Abs(os.Getenv("GAMES_DIR"))
absContentsDir, err := filepath.Abs(os.Getenv("FILE_CONTAINER_PATH"))
if err != nil {
http.Error(w, "Invalid contents directory path", http.StatusInternalServerError)
return
+7 -99
View File
@@ -4,7 +4,6 @@ import (
"fmt"
"net/http"
"os"
"path/filepath"
"teletype_softwares/domain"
"teletype_softwares/lib/template_utils"
@@ -13,59 +12,19 @@ import (
type PlayController struct {
softwareService domain.SoftwareServiceInterface
fileService domain.FileServiceInterface
}
func NewPlayController(softwareService domain.SoftwareServiceInterface) *PlayController {
func NewPlayController(softwareService domain.SoftwareServiceInterface, fileService domain.FileServiceInterface) *PlayController {
return &PlayController{
softwareService: softwareService,
fileService: fileService,
}
}
func (c *PlayController) PlayV1(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
if name == "" {
http.Error(w, "Name not provided", http.StatusBadRequest)
return
}
software, err := c.softwareService.GetByNameWithReleases(name)
if err != nil {
if err == domain.ErrSoftwareNotFound {
http.Error(w, "Software not found", http.StatusNotFound)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var webPlayableRelease *domain.Release
for _, release := range software.Releases {
if release.WebPlayable {
webPlayableRelease = &release
break
}
}
if webPlayableRelease == nil {
http.Error(w, "No web-playable version found for this software", http.StatusNotFound)
return
}
http.Redirect(w, r, fmt.Sprintf("/play/%s/%s", name, webPlayableRelease.Version), http.StatusFound)
}
func (c *PlayController) Play(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
if name == "" {
http.Error(w, "Name not provided", http.StatusBadRequest)
return
}
version := chi.URLParam(r, "version")
if version == "" {
http.Error(w, "Version not provided", http.StatusBadRequest)
return
}
software, err := c.softwareService.GetByNameWithReleases(name)
if err != nil {
@@ -79,7 +38,7 @@ func (c *PlayController) Play(w http.ResponseWriter, r *http.Request) {
var webPlayableRelease *domain.Release
for _, release := range software.Releases {
if release.Version == version && release.WebPlayable {
if release.Version == version && release.HTMLFolderPath != "" {
webPlayableRelease = &release
break
}
@@ -102,64 +61,13 @@ func (c *PlayController) Play(w http.ResponseWriter, r *http.Request) {
})
}
func (c *PlayController) ServeContentV1(w http.ResponseWriter, r *http.Request) {
contentsPath := os.Getenv("GAMES_DIR")
name := chi.URLParam(r, "name")
if name == "" {
http.Error(w, "Name not provided", http.StatusBadRequest)
return
}
software, err := c.softwareService.GetByNameWithReleases(name)
if err != nil {
http.Error(w, fmt.Sprintf("Content for '%s' not found.", name), http.StatusNotFound)
return
}
var webPlayableRelease *domain.Release
for _, release := range software.Releases {
if release.WebPlayable {
webPlayableRelease = &release
break
}
}
if webPlayableRelease == nil {
http.Error(w, "No web-playable version found for this software", http.StatusNotFound)
return
}
htmlBaseDir := filepath.Join(contentsPath, "html", name, webPlayableRelease.Version)
if _, err := os.Stat(htmlBaseDir); os.IsNotExist(err) {
http.Error(w, fmt.Sprintf("Content for '%s' not found.", name), http.StatusNotFound)
return
} else if err != nil {
http.Error(w, fmt.Sprintf("Error accessing content for '%s': %v", name, err), http.StatusInternalServerError)
return
}
fs := http.StripPrefix(fmt.Sprintf("/play/%s/content", name), http.FileServer(http.Dir(htmlBaseDir)))
fs.ServeHTTP(w, r)
}
func (c *PlayController) ServeContent(w http.ResponseWriter, r *http.Request) {
contentsPath := os.Getenv("GAMES_DIR")
name := chi.URLParam(r, "name")
if name == "" {
http.Error(w, "Name not provided", http.StatusBadRequest)
return
}
version := chi.URLParam(r, "version")
if version == "" {
http.Error(w, "Version not provided", http.StatusBadRequest)
return
}
htmlBaseDir := filepath.Join(contentsPath, name, version, "html")
html_base_dir := c.fileService.GetPath(name + "-" + version)
if _, err := os.Stat(htmlBaseDir); os.IsNotExist(err) {
if _, err := os.Stat(html_base_dir); os.IsNotExist(err) {
http.Error(w, fmt.Sprintf("Content for '%s' not found.", name), http.StatusNotFound)
return
} else if err != nil {
@@ -167,6 +75,6 @@ func (c *PlayController) ServeContent(w http.ResponseWriter, r *http.Request) {
return
}
fs := http.StripPrefix(fmt.Sprintf("/play/%s/%s/content", name, version), http.FileServer(http.Dir(htmlBaseDir)))
fs := http.StripPrefix(fmt.Sprintf("/play/%s/%s/content", name, version), http.FileServer(http.Dir(html_base_dir)))
fs.ServeHTTP(w, r)
}
+1 -1
View File
@@ -10,7 +10,7 @@ func StartHttpServer(domainInstance domain.Domain) {
NewSoftwareController(domainInstance.SoftwareService),
NewSoftwareUpdaterController(domainInstance.SoftwareUpdaterService),
NewDownloadController(domainInstance.DownloadService),
NewPlayController(domainInstance.SoftwareService),
NewPlayController(domainInstance.SoftwareService, domainInstance.FileService),
NewRootController(),
).Init()
+2 -3
View File
@@ -1,8 +1,9 @@
package http
import (
"github.com/go-chi/chi/v5"
"net/http"
"github.com/go-chi/chi/v5"
)
type Router struct {
@@ -40,8 +41,6 @@ func (r *Router) Init() *chi.Mux {
router.Get("/download/{name}/cartridge", r.downloadController.GetLatestCartridge)
router.Get("/download/{name}/{version}/source", r.downloadController.GetSource)
router.Get("/download/{name}/{version}/cartridge", r.downloadController.GetCartridge)
router.Get("/play/{name}", r.playController.PlayV1)
router.Get("/play/{name}/content*", r.playController.ServeContentV1)
router.Get("/play/{name}/{version}", r.playController.Play)
router.Get("/play/{name}/{version}/content*", r.playController.ServeContent)