Compare commits
No commits in common. "4d89da72ad167aa6445c97b5a494856c680c6f93" and "2261bf55f431f2d25acc343391d31d77eb0c802e" have entirely different histories.
4d89da72ad
...
2261bf55f4
40
app.go
40
app.go
|
@ -23,7 +23,7 @@ type App struct {
|
||||||
src string
|
src string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Init(srcListenAddr string, logjamBaseUrl string, iceTCPMUXListenPort uint, customICEHostCandidateIP string) {
|
func (a *App) Init(srcListenAddr string, svcAddr string, logjamBaseUrl string, targetRoom string, iceTCPMUXListenPort uint, customICEHostCandidateIP string) {
|
||||||
println("initializing ..")
|
println("initializing ..")
|
||||||
a.src = srcListenAddr
|
a.src = srcListenAddr
|
||||||
var iceServers []webrtc.ICEServer
|
var iceServers []webrtc.ICEServer
|
||||||
|
@ -36,9 +36,11 @@ func (a *App) Init(srcListenAddr string, logjamBaseUrl string, iceTCPMUXListenPo
|
||||||
panic("[E] can't parse ice.servers.json: " + err.Error())
|
panic("[E] can't parse ice.servers.json: " + err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
startRejoinCH := make(chan models.RejoinMode, 2)
|
startRejoinCH := make(chan bool, 2)
|
||||||
a.conf = &models.ConfigModel{
|
a.conf = &models.ConfigModel{
|
||||||
LogjamBaseUrl: logjamBaseUrl,
|
LogjamBaseUrl: logjamBaseUrl + "/auxiliary-node",
|
||||||
|
TargetRoom: targetRoom,
|
||||||
|
ServiceAddress: svcAddr,
|
||||||
ICEServers: iceServers,
|
ICEServers: iceServers,
|
||||||
ICETCPMUXListenPort: iceTCPMUXListenPort,
|
ICETCPMUXListenPort: iceTCPMUXListenPort,
|
||||||
CustomICEHostCandidateIP: customICEHostCandidateIP,
|
CustomICEHostCandidateIP: customICEHostCandidateIP,
|
||||||
|
@ -63,59 +65,51 @@ func (a *App) Init(srcListenAddr string, logjamBaseUrl string, iceTCPMUXListenPo
|
||||||
|
|
||||||
func (a *App) Run() {
|
func (a *App) Run() {
|
||||||
go func() {
|
go func() {
|
||||||
//*a.conf.StartRejoinCH <- true
|
*a.conf.StartRejoinCH <- true
|
||||||
|
for simplyJoin := range *a.conf.StartRejoinCH {
|
||||||
|
if simplyJoin {
|
||||||
|
buffer, _ := json.Marshal(map[string]any{"roomId": a.conf.TargetRoom, "svcAddr": a.conf.ServiceAddress})
|
||||||
|
body := bytes.NewReader(buffer)
|
||||||
c := &http.Client{
|
c := &http.Client{
|
||||||
Timeout: 8 * time.Second,
|
Timeout: 8 * time.Second,
|
||||||
}
|
}
|
||||||
for data := range *a.conf.StartRejoinCH {
|
|
||||||
if data.SimplyJoin {
|
|
||||||
buffer, _ := json.Marshal(map[string]any{"roomId": data.RoomId})
|
|
||||||
body := bytes.NewReader(buffer)
|
|
||||||
res, err := c.Post(a.conf.LogjamBaseUrl+"/join", "application/json", body)
|
res, err := c.Post(a.conf.LogjamBaseUrl+"/join", "application/json", body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
println(err.Error())
|
println(err.Error())
|
||||||
time.Sleep(4 * time.Second)
|
time.Sleep(4 * time.Second)
|
||||||
*a.conf.StartRejoinCH <- data
|
*a.conf.StartRejoinCH <- true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if res.StatusCode > 204 {
|
if res.StatusCode > 204 {
|
||||||
resbody, _ := io.ReadAll(res.Body)
|
resbody, _ := io.ReadAll(res.Body)
|
||||||
println("get /join "+res.Status, string(resbody))
|
println("get /join "+res.Status, string(resbody))
|
||||||
time.Sleep(4 * time.Second)
|
time.Sleep(4 * time.Second)
|
||||||
*a.conf.StartRejoinCH <- data
|
*a.conf.StartRejoinCH <- true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
data.SimplyJoin = true
|
|
||||||
reqModel := struct {
|
reqModel := struct {
|
||||||
RoomId string `json:"roomId"`
|
RoomId string `json:"roomId"`
|
||||||
}{
|
}{
|
||||||
RoomId: data.RoomId,
|
RoomId: a.conf.TargetRoom,
|
||||||
}
|
}
|
||||||
serializedReqBody, err := json.Marshal(reqModel)
|
serializedReqBody, err := json.Marshal(reqModel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
println(err.Error())
|
println(err.Error())
|
||||||
*a.conf.StartRejoinCH <- data
|
*a.conf.StartRejoinCH <- true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := c.Post(a.conf.LogjamBaseUrl+"/rejoin", "application/json", bytes.NewReader(serializedReqBody))
|
resp, err := http.Post(a.conf.LogjamBaseUrl+"/rejoin", "application/json", bytes.NewReader(serializedReqBody))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
println(err.Error())
|
println(err.Error())
|
||||||
|
*a.conf.StartRejoinCH <- true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if resp.StatusCode > 204 {
|
if resp.StatusCode > 204 {
|
||||||
println("/rejoin", resp.Status)
|
println("/rejoin", resp.Status)
|
||||||
|
*a.conf.StartRejoinCH <- true
|
||||||
}
|
}
|
||||||
/*if err != nil {
|
|
||||||
println(err.Error())
|
|
||||||
*a.conf.StartRejoinCH <- data
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if resp.StatusCode > 204 {
|
|
||||||
println("/rejoin", resp.Status)
|
|
||||||
*a.conf.StartRejoinCH <- data
|
|
||||||
}*/
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
|
@ -36,7 +36,7 @@ func (c *RoomController) CreatePeer(ctx *gin.Context) {
|
||||||
c.helper.ResponseUnprocessableEntity(ctx)
|
c.helper.ResponseUnprocessableEntity(ctx)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err := c.repo.CreatePeer(reqModel.RoomId, reqModel.ID, reqModel.CanPublish, reqModel.IsCaller, reqModel.GGID)
|
err := c.repo.CreatePeer(reqModel.RoomId, reqModel.ID, reqModel.CanPublish, reqModel.IsCaller)
|
||||||
if c.helper.HandleIfErr(ctx, err, nil) {
|
if c.helper.HandleIfErr(ctx, err, nil) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -88,13 +88,11 @@ func (c *RoomController) Offer(ctx *gin.Context) {
|
||||||
}
|
}
|
||||||
c.helper.Response(ctx, struct{}{}, http.StatusNoContent)
|
c.helper.Response(ctx, struct{}{}, http.StatusNoContent)
|
||||||
{
|
{
|
||||||
ggid := c.repo.GetRoomGGID(reqModel.RoomId)
|
|
||||||
buffer, err := json.Marshal(dto.SetSDPReqModel{
|
buffer, err := json.Marshal(dto.SetSDPReqModel{
|
||||||
PeerDTO: dto.PeerDTO{
|
PeerDTO: dto.PeerDTO{
|
||||||
RoomId: reqModel.RoomId,
|
RoomId: reqModel.RoomId,
|
||||||
ID: reqModel.ID,
|
ID: reqModel.ID,
|
||||||
},
|
},
|
||||||
GGID: *ggid,
|
|
||||||
SDP: *answer,
|
SDP: *answer,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -167,50 +165,25 @@ func (c *RoomController) ResetRoom(ctx *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ggid, err := c.repo.ResetRoom(roomId)
|
err := c.repo.ResetRoom(roomId)
|
||||||
if c.helper.HandleIfErr(ctx, err, nil) {
|
if c.helper.HandleIfErr(ctx, err, nil) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.helper.Response(ctx, struct {
|
c.helper.Response(ctx, nil, http.StatusNoContent)
|
||||||
GGID uint64 `json:"ggid"`
|
|
||||||
}{
|
|
||||||
GGID: ggid,
|
|
||||||
}, http.StatusOK)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *RoomController) Start(ctx *gin.Context) {
|
func (c *RoomController) Start(ctx *gin.Context) {
|
||||||
reqModel := struct {
|
buffer, _ := json.Marshal(map[string]any{"roomId": c.conf.TargetRoom, "svcAddr": c.conf.ServiceAddress})
|
||||||
RoomId string `json:"roomId"`
|
|
||||||
}{}
|
|
||||||
if err := ctx.ShouldBindJSON(&reqModel); err != nil {
|
|
||||||
c.helper.ResponseBadReq(ctx)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
buffer, _ := json.Marshal(map[string]any{"roomId": reqModel.RoomId})
|
|
||||||
body := bytes.NewReader(buffer)
|
body := bytes.NewReader(buffer)
|
||||||
res, err := http.Post(c.conf.LogjamBaseUrl+"/join", "application/json", body)
|
res, err := http.Post(c.conf.LogjamBaseUrl+"/join", "application/json", body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
println(err.Error())
|
println(err.Error())
|
||||||
time.Sleep(4 * time.Second)
|
time.Sleep(4 * time.Second)
|
||||||
}
|
}
|
||||||
if res != nil {
|
if res != nil && res.StatusCode > 204 {
|
||||||
if res.StatusCode > 204 {
|
|
||||||
resbody, _ := io.ReadAll(res.Body)
|
resbody, _ := io.ReadAll(res.Body)
|
||||||
println("get /join "+res.Status, string(resbody))
|
println("get /join "+res.Status, string(resbody))
|
||||||
} else {
|
|
||||||
resbody, _ := io.ReadAll(res.Body)
|
|
||||||
println(string(resbody))
|
|
||||||
respData := make(map[string]any)
|
|
||||||
if len(resbody) > 2 {
|
|
||||||
err := json.Unmarshal(resbody, &respData)
|
|
||||||
if err != nil {
|
|
||||||
println(err.Error())
|
|
||||||
c.helper.Response(ctx, nil, http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
c.helper.Response(ctx, nil, http.StatusNoContent)
|
c.helper.Response(ctx, nil, http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
8
main.go
8
main.go
|
@ -6,8 +6,10 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
svcAddr := flag.String("svc-addr", "http://localhost:8080", "service baseurl to register in logjam ( shouldn't end with / )")
|
||||||
src := flag.String("src", ":8080", "listenHost:listenPort")
|
src := flag.String("src", ":8080", "listenHost:listenPort")
|
||||||
logjamBaseUrl := flag.String("logjam-base-url", "http://localhost:8090", "logjam base url( shouldn't end with / )")
|
logjamBaseUrl := flag.String("logjam-base-url", "http://localhost:8090", "logjam base url( shouldn't end with / )")
|
||||||
|
targetRoom := flag.String("targetRoom", "test", "target room")
|
||||||
icetcpmuxListenPort := flag.Uint("ice-tcp-mux-listen-port", 4444, "listen port to use for tcp ice candidates")
|
icetcpmuxListenPort := flag.Uint("ice-tcp-mux-listen-port", 4444, "listen port to use for tcp ice candidates")
|
||||||
customICEHostCandidateIP := flag.String("custom-ice-host-candidate-ip", "", "set to override host ice candidates address")
|
customICEHostCandidateIP := flag.String("custom-ice-host-candidate-ip", "", "set to override host ice candidates address")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
@ -15,8 +17,10 @@ func main() {
|
||||||
if strings.HasSuffix(*logjamBaseUrl, "/") {
|
if strings.HasSuffix(*logjamBaseUrl, "/") {
|
||||||
panic("logjam-base-url shouldn't end with /")
|
panic("logjam-base-url shouldn't end with /")
|
||||||
}
|
}
|
||||||
|
if strings.HasSuffix(*svcAddr, "/") {
|
||||||
|
panic("service address shouldn't end with /")
|
||||||
|
}
|
||||||
app := App{}
|
app := App{}
|
||||||
*logjamBaseUrl += "/goldgorilla"
|
app.Init(*src, *svcAddr, *logjamBaseUrl, *targetRoom, *icetcpmuxListenPort, *customICEHostCandidateIP)
|
||||||
app.Init(*src, *logjamBaseUrl, *icetcpmuxListenPort, *customICEHostCandidateIP)
|
|
||||||
app.Run()
|
app.Run()
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,14 +2,12 @@ package models
|
||||||
|
|
||||||
import "github.com/pion/webrtc/v3"
|
import "github.com/pion/webrtc/v3"
|
||||||
|
|
||||||
type RejoinMode struct {
|
|
||||||
SimplyJoin bool
|
|
||||||
RoomId string
|
|
||||||
}
|
|
||||||
type ConfigModel struct {
|
type ConfigModel struct {
|
||||||
|
ServiceAddress string `json:"serviceAddress"`
|
||||||
LogjamBaseUrl string `json:"logjamBaseUrl"`
|
LogjamBaseUrl string `json:"logjamBaseUrl"`
|
||||||
|
TargetRoom string `json:"targetRoom"`
|
||||||
ICETCPMUXListenPort uint `json:"ice_tcpmux_listenPort"`
|
ICETCPMUXListenPort uint `json:"ice_tcpmux_listenPort"`
|
||||||
CustomICEHostCandidateIP string `json:"customICEHostCandidateIP"`
|
CustomICEHostCandidateIP string `json:"customICEHostCandidateIP"`
|
||||||
ICEServers []webrtc.ICEServer `json:"iceServers"`
|
ICEServers []webrtc.ICEServer `json:"iceServers"`
|
||||||
StartRejoinCH *chan RejoinMode
|
StartRejoinCH *chan bool
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,14 +13,12 @@ func (model *PeerDTO) Validate() bool {
|
||||||
|
|
||||||
type CreatePeerReqModel struct {
|
type CreatePeerReqModel struct {
|
||||||
PeerDTO
|
PeerDTO
|
||||||
GGID uint64 `json:"ggid"`
|
|
||||||
CanPublish bool `json:"canPublish"`
|
CanPublish bool `json:"canPublish"`
|
||||||
IsCaller bool `json:"isCaller"`
|
IsCaller bool `json:"isCaller"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AddPeerICECandidateReqModel struct {
|
type AddPeerICECandidateReqModel struct {
|
||||||
PeerDTO
|
PeerDTO
|
||||||
GGID uint64 `json:"ggid"`
|
|
||||||
ICECandidate webrtc.ICECandidateInit `json:"iceCandidate"`
|
ICECandidate webrtc.ICECandidateInit `json:"iceCandidate"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -30,7 +28,6 @@ func (model *AddPeerICECandidateReqModel) Validate() bool {
|
||||||
|
|
||||||
type SetSDPReqModel struct {
|
type SetSDPReqModel struct {
|
||||||
PeerDTO
|
PeerDTO
|
||||||
GGID uint64 `json:"ggid"`
|
|
||||||
SDP webrtc.SessionDescription `json:"sdp"`
|
SDP webrtc.SessionDescription `json:"sdp"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -38,7 +38,6 @@ type Room struct {
|
||||||
trackLock *sync.Mutex
|
trackLock *sync.Mutex
|
||||||
Tracks map[string]*Track
|
Tracks map[string]*Track
|
||||||
timer *time.Ticker
|
timer *time.Ticker
|
||||||
ggId uint64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type RoomRepository struct {
|
type RoomRepository struct {
|
||||||
|
@ -115,7 +114,7 @@ func (r *RoomRepository) doesPeerExists(roomId string, id uint64) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RoomRepository) CreatePeer(roomId string, id uint64, canPublish bool, isCaller bool, ggid uint64) error {
|
func (r *RoomRepository) CreatePeer(roomId string, id uint64, canPublish bool, isCaller bool) error {
|
||||||
r.Lock()
|
r.Lock()
|
||||||
|
|
||||||
if !r.doesRoomExists(roomId) {
|
if !r.doesRoomExists(roomId) {
|
||||||
|
@ -125,7 +124,6 @@ func (r *RoomRepository) CreatePeer(roomId string, id uint64, canPublish bool, i
|
||||||
trackLock: &sync.Mutex{},
|
trackLock: &sync.Mutex{},
|
||||||
Tracks: make(map[string]*Track),
|
Tracks: make(map[string]*Track),
|
||||||
timer: time.NewTicker(3 * time.Second),
|
timer: time.NewTicker(3 * time.Second),
|
||||||
ggId: ggid,
|
|
||||||
}
|
}
|
||||||
r.Rooms[roomId] = room
|
r.Rooms[roomId] = room
|
||||||
go func() {
|
go func() {
|
||||||
|
@ -166,7 +164,7 @@ func (r *RoomRepository) CreatePeer(roomId string, id uint64, canPublish bool, i
|
||||||
}
|
}
|
||||||
|
|
||||||
peerConn.OnICECandidate(func(ic *webrtc.ICECandidate) {
|
peerConn.OnICECandidate(func(ic *webrtc.ICECandidate) {
|
||||||
r.onPeerICECandidate(roomId, id, room.ggId, ic)
|
r.onPeerICECandidate(roomId, id, ic)
|
||||||
})
|
})
|
||||||
peerConn.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
peerConn.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||||
r.Lock()
|
r.Lock()
|
||||||
|
@ -211,17 +209,14 @@ func (r *RoomRepository) CreatePeer(roomId string, id uint64, canPublish bool, i
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RoomRepository) onCallerDisconnected(roomId string) {
|
func (r *RoomRepository) onCallerDisconnected(roomId string) {
|
||||||
if _, err := r.ResetRoom(roomId); err != nil {
|
if err := r.ResetRoom(roomId); err != nil {
|
||||||
println(err.Error())
|
println(err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
*r.conf.StartRejoinCH <- models.RejoinMode{
|
*r.conf.StartRejoinCH <- false
|
||||||
SimplyJoin: false,
|
|
||||||
RoomId: roomId,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RoomRepository) onPeerICECandidate(roomId string, id, ggid uint64, ic *webrtc.ICECandidate) {
|
func (r *RoomRepository) onPeerICECandidate(roomId string, id uint64, ic *webrtc.ICECandidate) {
|
||||||
if ic == nil {
|
if ic == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -230,7 +225,6 @@ func (r *RoomRepository) onPeerICECandidate(roomId string, id, ggid uint64, ic *
|
||||||
RoomId: roomId,
|
RoomId: roomId,
|
||||||
ID: id,
|
ID: id,
|
||||||
},
|
},
|
||||||
GGID: ggid,
|
|
||||||
ICECandidate: ic.ToJSON(),
|
ICECandidate: ic.ToJSON(),
|
||||||
}
|
}
|
||||||
serializedReqBody, err := json.Marshal(reqModel)
|
serializedReqBody, err := json.Marshal(reqModel)
|
||||||
|
@ -524,15 +518,14 @@ func (r *RoomRepository) ClosePeer(roomId string, id uint64) error {
|
||||||
return peer.Conn.Close()
|
return peer.Conn.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RoomRepository) ResetRoom(roomId string) (uint64, error) {
|
func (r *RoomRepository) ResetRoom(roomId string) error {
|
||||||
r.Lock()
|
r.Lock()
|
||||||
defer r.Unlock()
|
defer r.Unlock()
|
||||||
if !r.doesRoomExists(roomId) {
|
if !r.doesRoomExists(roomId) {
|
||||||
return 0, nil
|
return nil
|
||||||
}
|
}
|
||||||
room := r.Rooms[roomId]
|
room := r.Rooms[roomId]
|
||||||
room.Lock()
|
room.Lock()
|
||||||
ggid := room.ggId
|
|
||||||
room.timer.Stop()
|
room.timer.Stop()
|
||||||
for _, peer := range room.Peers {
|
for _, peer := range room.Peers {
|
||||||
go func(conn *webrtc.PeerConnection) {
|
go func(conn *webrtc.PeerConnection) {
|
||||||
|
@ -541,7 +534,7 @@ func (r *RoomRepository) ResetRoom(roomId string) (uint64, error) {
|
||||||
}
|
}
|
||||||
room.Unlock()
|
room.Unlock()
|
||||||
delete(r.Rooms, roomId)
|
delete(r.Rooms, roomId)
|
||||||
return ggid, nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RoomRepository) offerPeer(peer *Peer, roomId string) error {
|
func (r *RoomRepository) offerPeer(peer *Peer, roomId string) error {
|
||||||
|
@ -555,9 +548,7 @@ func (r *RoomRepository) offerPeer(peer *Peer, roomId string) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
ggid := r.GetRoomGGID(roomId)
|
|
||||||
reqModel := dto.SetSDPReqModel{
|
reqModel := dto.SetSDPReqModel{
|
||||||
GGID: *ggid,
|
|
||||||
PeerDTO: dto.PeerDTO{
|
PeerDTO: dto.PeerDTO{
|
||||||
RoomId: roomId,
|
RoomId: roomId,
|
||||||
ID: peer.ID,
|
ID: peer.ID,
|
||||||
|
@ -577,13 +568,3 @@ func (r *RoomRepository) offerPeer(peer *Peer, roomId string) error {
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RoomRepository) GetRoomGGID(roomId string) *uint64 {
|
|
||||||
r.Lock()
|
|
||||||
defer r.Unlock()
|
|
||||||
if !r.doesRoomExists(roomId) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
gid := r.Rooms[roomId].ggId
|
|
||||||
return &gid
|
|
||||||
}
|
|
||||||
|
|
Loading…
Reference in New Issue