another halfway-through commit, ignore...
This commit is contained in:
parent
620043f48c
commit
828e4691da
2
.gitignore
vendored
2
.gitignore
vendored
@ -2,6 +2,8 @@ src/magneticod/vendor
|
||||
src/magneticod/Gopkg.lock
|
||||
src/magneticow/vendor
|
||||
src/magneticow/Gopkg.lock
|
||||
src/persistence/vendor
|
||||
src/persistence/Gopkg.lock
|
||||
|
||||
# Created by https://www.gitignore.io/api/linux,python,pycharm
|
||||
|
||||
|
@ -1,15 +1,16 @@
|
||||
sudo: enabled
|
||||
dist: xenial
|
||||
language: python
|
||||
python:
|
||||
- "3.5"
|
||||
# versions 3.6.0 and 3.6.1 have bugs that affect magneticod
|
||||
language: go
|
||||
go:
|
||||
- 1.8.3
|
||||
|
||||
before_install:
|
||||
- "sudo apt-get update -qq"
|
||||
- "sudo apt-get install python3-dev"
|
||||
- "pip3 install mypy pylint"
|
||||
|
||||
- ""
|
||||
|
||||
install:
|
||||
- "pip3 install ./magneticod"
|
||||
- "pip3 install ./magneticow"
|
||||
|
@ -1,27 +0,0 @@
|
||||
main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
|
||||
height: calc(100vh - 2 * 3em);
|
||||
width: calc(100vw - 2 * 3em);
|
||||
}
|
||||
|
||||
main form {
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
|
||||
main form input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
main > div {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 0.833em;
|
||||
}
|
@ -1,23 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>magneticow</title>
|
||||
<link rel="stylesheet" href=" {{ url_for('static', filename='styles/reset.css') }} ">
|
||||
<link rel="stylesheet" href=" {{ url_for('static', filename='styles/essential.css') }} ">
|
||||
<link rel="stylesheet" href=" {{ url_for('static', filename='styles/homepage.css') }} ">
|
||||
<!-- <script src="script.js"></script> -->
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div><b>magnetico<sup>w</sup></b>​<sub>(pre-alpha)</sub></div>
|
||||
<form action="/torrents" method="get" autocomplete="off" role="search">
|
||||
<input type="search" name="search" placeholder="Search the BitTorrent DHT" autofocus>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
~{{ "{:,}".format(n_torrents) }} torrents available (see the <a href="/statistics">statistics</a>).
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
@ -1,90 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{% if search %}"{{search}}"{% else %}Most recent torrents{% endif %} - magneticow</title>
|
||||
<link rel="stylesheet" href=" {{ url_for('static', filename='styles/reset.css') }} ">
|
||||
<link rel="stylesheet" href=" {{ url_for('static', filename='styles/essential.css') }} ">
|
||||
<link rel="stylesheet" href=" {{ url_for('static', filename='styles/torrents.css') }} ">
|
||||
<!-- <script src="script.js"></script> -->
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div><a href="/"><b>magnetico<sup>w</sup></b></a>​<sub>(pre-alpha)</sub></div>
|
||||
<form action="/torrents" method="get" autocomplete="off" role="search">
|
||||
<input type="search" name="search" placeholder="Search the BitTorrent DHT" value="{{ search }}">
|
||||
</form>
|
||||
<div>
|
||||
<a href="{{ subscription_url }}"><img src="{{ url_for('static', filename='assets/feed.png') }}"
|
||||
alt="feed icon" title="subscribe" /> subscribe</a>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><!-- Magnet link --></th>
|
||||
<th>
|
||||
{% if sorted_by == "name ASC" %}
|
||||
<a href="/torrents/?search={{ search }}&sort_by=name+DESC">Name ▲</a>
|
||||
{% elif sorted_by == "name DESC" %}
|
||||
<a href="/torrents/?search={{ search }}&sort_by=name+ASC">Name ▼</a>
|
||||
{% else %}
|
||||
<a href="/torrents/?search={{ search }}&sort_by=name+ASC">Name</a>
|
||||
{% endif %}
|
||||
</th>
|
||||
<th>
|
||||
{% if sorted_by == "total_size ASC" %}
|
||||
<a href="/torrents/?search={{ search }}&sort_by=total_size+DESC">Size ▲</a>
|
||||
{% elif sorted_by == "total_size DESC" %}
|
||||
<a href="/torrents/?search={{ search }}&sort_by=total_size+ASC">Size ▼</a>
|
||||
{% else %}
|
||||
<a href="/torrents/?search={{ search }}&sort_by=total_size+ASC">Size</a>
|
||||
{% endif %}
|
||||
</th>
|
||||
<th>
|
||||
{% if sorted_by == "discovered_on ASC" %}
|
||||
<a href="/torrents/?search={{ search }}&sort_by=discovered_on+DESC">Discovered on ▲</a>
|
||||
{% elif sorted_by == "discovered_on DESC" %}
|
||||
<a href="/torrents/?search={{ search }}&sort_by=discovered_on+ASC">Discovered on ▼</a>
|
||||
{% else %}
|
||||
<a href="/torrents/?search={{ search }}&sort_by=discovered_on+DESC">Discovered on</a>
|
||||
{% endif %}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for torrent in torrents %}
|
||||
<tr>
|
||||
<td><a href="magnet:?xt=urn:btih:{{ torrent.info_hash }}&dn={{ torrent.name }}">
|
||||
<img src="{{ url_for('static', filename='assets/magnet.gif') }}" alt="Magnet link"
|
||||
title="Download this torrent using magnet" /></a></td>
|
||||
<td><a href="/torrents/{{ torrent.info_hash }}/{{ torrent.name }}">{{ torrent.name }}</a></td>
|
||||
<td>{{ torrent.size }}</td>
|
||||
<td>{{ torrent.discovered_on }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
<footer>
|
||||
|
||||
<form action="/torrents" method="get">
|
||||
<button {% if page == 0 %}disabled{% endif %}>Previous</button>
|
||||
<input type="text" name="search" value="{{ search }}" hidden>
|
||||
{% if sorted_by %}
|
||||
<input type="text" name="sort_by" value="{{ sorted_by }}" hidden>
|
||||
{% endif %}
|
||||
<input type="number" name="page" value="{{ page - 1 }}" hidden>
|
||||
</form>
|
||||
<form action="/torrents" method="get">
|
||||
<button {% if not next_page_exists %}disabled{% endif %}>Next</button>
|
||||
<input type="text" name="search" value="{{ search }}" hidden>
|
||||
{% if sorted_by %}
|
||||
<input type="text" name="sort_by" value="{{ sorted_by }}" hidden>
|
||||
{% endif %}
|
||||
<input type="number" name="page" value="{{ page + 1 }}" hidden>
|
||||
</form>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
@ -8,6 +8,9 @@ import (
|
||||
"github.com/anacrolix/torrent"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
"go.uber.org/zap"
|
||||
"os"
|
||||
"path"
|
||||
"persistence"
|
||||
)
|
||||
|
||||
|
||||
@ -40,48 +43,46 @@ func (ms *MetadataSink) awaitMetadata(infoHash metainfo.Hash, peer torrent.Peer)
|
||||
return
|
||||
}
|
||||
|
||||
var files []metainfo.FileInfo
|
||||
if len(info.Files) == 0 {
|
||||
if strings.ContainsRune(info.Name, '/') {
|
||||
// A single file torrent cannot have any '/' characters in its name. We treat it as
|
||||
// illegal.
|
||||
zap.L().Sugar().Debugf("!!!! illegal character in name! \"%s\"", info.Name)
|
||||
return
|
||||
}
|
||||
files = []metainfo.FileInfo{{Length: info.Length, Path:[]string{info.Name}}}
|
||||
} else {
|
||||
// TODO: We have to make sure that anacrolix/torrent checks for '/' character in file paths
|
||||
// before concatenating them. This is currently assumed here. We should write a test for it.
|
||||
files = info.Files
|
||||
var files []persistence.File
|
||||
for _, file := range info.Files {
|
||||
files = append(files, persistence.File{
|
||||
Size: file.Length,
|
||||
Path: file.DisplayPath(info),
|
||||
})
|
||||
}
|
||||
|
||||
var totalSize uint64
|
||||
for _, file := range files {
|
||||
if file.Length < 0 {
|
||||
if file.Size < 0 {
|
||||
// All files' sizes must be greater than or equal to zero, otherwise treat them as
|
||||
// illegal and ignore.
|
||||
zap.L().Sugar().Debugf("!!!! file size zero or less! \"%s\" (%d)", file.Path, file.Length)
|
||||
zap.L().Sugar().Debugf("!!!! file size zero or less! \"%s\" (%d)", file.Path, file.Size)
|
||||
return
|
||||
}
|
||||
totalSize += uint64(file.Length)
|
||||
totalSize += uint64(file.Size)
|
||||
}
|
||||
|
||||
ms.flush(Metadata{
|
||||
InfoHash: infoHash[:],
|
||||
Name: info.Name,
|
||||
TotalSize: totalSize,
|
||||
InfoHash: infoHash[:],
|
||||
Name: info.Name,
|
||||
TotalSize: totalSize,
|
||||
DiscoveredOn: time.Now().Unix(),
|
||||
Files: files,
|
||||
Files: files,
|
||||
Peers: nil,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
func (fs *FileSink) awaitFile(infoHash []byte, filePath string, peer *torrent.Peer) {
|
||||
func (fs *FileSink) awaitFile(req *FileRequest) {
|
||||
// Remove the download directory of the torrent after the operation is completed.
|
||||
// TODO: what if RemoveAll() returns error, do we care, and if we do, how to handle it?
|
||||
defer os.RemoveAll(path.Join(fs.baseDownloadDir, string(req.InfoHash)))
|
||||
|
||||
var infoHash_ [20]byte
|
||||
copy(infoHash_[:], infoHash)
|
||||
copy(infoHash_[:], req.InfoHash)
|
||||
t, isNew := fs.client.AddTorrentInfoHash(infoHash_)
|
||||
if peer != nil {
|
||||
t.AddPeers([]torrent.Peer{*peer})
|
||||
if len(req.Peers) > 0 {
|
||||
t.AddPeers(req.Peers)
|
||||
}
|
||||
if !isNew {
|
||||
// Return immediately if we are trying to await on an ongoing file-downloading operation.
|
||||
@ -90,7 +91,7 @@ func (fs *FileSink) awaitFile(infoHash []byte, filePath string, peer *torrent.Pe
|
||||
}
|
||||
|
||||
// Setup & start the timeout timer.
|
||||
timeout := time.After(fs.timeout)
|
||||
timeout := time.After(fs.timeoutDuration)
|
||||
|
||||
// Once we return from this function, drop the torrent from the client.
|
||||
// TODO: Check if dropping a torrent also cancels any outstanding read operations?
|
||||
@ -105,7 +106,7 @@ func (fs *FileSink) awaitFile(infoHash []byte, filePath string, peer *torrent.Pe
|
||||
|
||||
var match *torrent.File
|
||||
for _, file := range t.Files() {
|
||||
if file.Path() == filePath {
|
||||
if file.Path() == req.Path {
|
||||
match = &file
|
||||
} else {
|
||||
file.Cancel()
|
||||
@ -117,13 +118,12 @@ func (fs *FileSink) awaitFile(infoHash []byte, filePath string, peer *torrent.Pe
|
||||
|
||||
zap.L().Warn(
|
||||
"The leech (FileSink) has been requested to download a file which does not exist!",
|
||||
zap.ByteString("torrent", infoHash),
|
||||
zap.String("requestedFile", filePath),
|
||||
zap.ByteString("torrent", req.InfoHash),
|
||||
zap.String("requestedFile", req.Path),
|
||||
zap.Strings("allFiles", filePaths),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
reader := t.NewReader()
|
||||
defer reader.Close()
|
||||
|
||||
@ -133,18 +133,17 @@ func (fs *FileSink) awaitFile(infoHash []byte, filePath string, peer *torrent.Pe
|
||||
select {
|
||||
case fileData := <-fileDataChan:
|
||||
if fileData != nil {
|
||||
fs.flush(File{
|
||||
torrentInfoHash: infoHash,
|
||||
path: match.Path(),
|
||||
data: fileData,
|
||||
fs.flush(FileResult{
|
||||
Request: req,
|
||||
FileData: fileData,
|
||||
})
|
||||
}
|
||||
|
||||
case <- timeout:
|
||||
zap.L().Debug(
|
||||
"Timeout while downloading a file!",
|
||||
zap.ByteString("torrent", infoHash),
|
||||
zap.String("file", filePath),
|
||||
zap.ByteString("torrent", req.InfoHash),
|
||||
zap.String("file", req.Path),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -156,9 +155,10 @@ func downloadFile(file torrent.File, reader *torrent.Reader, fileDataChan chan<-
|
||||
fileData := make([]byte, file.Length())
|
||||
n, err := readSeeker.Read(fileData)
|
||||
if int64(n) != file.Length() {
|
||||
infoHash := file.Torrent().InfoHash()
|
||||
zap.L().Debug(
|
||||
"Not all of a file could be read!",
|
||||
zap.ByteString("torrent", file.Torrent().InfoHash()[:]),
|
||||
zap.ByteString("torrent", infoHash[:]),
|
||||
zap.String("file", file.Path()),
|
||||
zap.Int64("fileLength", file.Length()),
|
||||
zap.Int("n", n),
|
||||
@ -167,10 +167,11 @@ func downloadFile(file torrent.File, reader *torrent.Reader, fileDataChan chan<-
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
infoHash := file.Torrent().InfoHash()
|
||||
zap.L().Debug(
|
||||
"Error while downloading a file!",
|
||||
zap.Error(err),
|
||||
zap.ByteString("torrent", file.Torrent().InfoHash()[:]),
|
||||
zap.ByteString("torrent", infoHash[:]),
|
||||
zap.String("file", file.Path()),
|
||||
zap.Int64("fileLength", file.Length()),
|
||||
zap.Int("n", n),
|
||||
|
@ -12,28 +12,33 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
|
||||
type File struct{
|
||||
torrentInfoHash []byte
|
||||
path string
|
||||
data []byte
|
||||
type FileRequest struct {
|
||||
InfoHash []byte
|
||||
Path string
|
||||
Peers []torrent.Peer
|
||||
}
|
||||
|
||||
type FileResult struct {
|
||||
// Request field is the original Request
|
||||
Request *FileRequest
|
||||
FileData []byte
|
||||
}
|
||||
|
||||
type FileSink struct {
|
||||
baseDownloadDir string
|
||||
client *torrent.Client
|
||||
drain chan File
|
||||
drain chan FileResult
|
||||
terminated bool
|
||||
termination chan interface{}
|
||||
|
||||
timeout time.Duration
|
||||
timeoutDuration time.Duration
|
||||
}
|
||||
|
||||
// NewFileSink creates a new FileSink.
|
||||
//
|
||||
// cAddr : client address
|
||||
// mlAddr: mainline DHT node address
|
||||
func NewFileSink(cAddr, mlAddr string, timeout time.Duration) *FileSink {
|
||||
func NewFileSink(cAddr, mlAddr string, timeoutDuration time.Duration) *FileSink {
|
||||
fs := new(FileSink)
|
||||
|
||||
mlUDPAddr, err := net.ResolveUDPAddr("udp", mlAddr)
|
||||
@ -49,6 +54,11 @@ func NewFileSink(cAddr, mlAddr string, timeout time.Duration) *FileSink {
|
||||
return nil
|
||||
}
|
||||
|
||||
fs.baseDownloadDir = path.Join(
|
||||
appdirs.UserCacheDir("magneticod", "", "", true),
|
||||
"downloads",
|
||||
)
|
||||
|
||||
fs.client, err = torrent.NewClient(&torrent.Config{
|
||||
ListenAddr: cAddr,
|
||||
DisableTrackers: true,
|
||||
@ -57,10 +67,7 @@ func NewFileSink(cAddr, mlAddr string, timeout time.Duration) *FileSink {
|
||||
Passive: true,
|
||||
NoSecurity: true,
|
||||
},
|
||||
DefaultStorage: storage.NewFileByInfoHash(path.Join(
|
||||
appdirs.UserCacheDir("magneticod", "", "", true),
|
||||
"downloads",
|
||||
)),
|
||||
DefaultStorage: storage.NewFileByInfoHash(fs.baseDownloadDir),
|
||||
})
|
||||
if err != nil {
|
||||
zap.L().Fatal("Leech could NOT create a new torrent client!", zap.Error(err))
|
||||
@ -68,21 +75,26 @@ func NewFileSink(cAddr, mlAddr string, timeout time.Duration) *FileSink {
|
||||
return nil
|
||||
}
|
||||
|
||||
fs.drain = make(chan File)
|
||||
fs.drain = make(chan FileResult)
|
||||
fs.termination = make(chan interface{})
|
||||
fs.timeout = timeout
|
||||
fs.timeoutDuration = timeoutDuration
|
||||
|
||||
return fs
|
||||
}
|
||||
|
||||
|
||||
// peer might be nil
|
||||
func (fs *FileSink) Sink(infoHash []byte, filePath string, peer *torrent.Peer) {
|
||||
go fs.awaitFile(infoHash, filePath, peer)
|
||||
// peer field is optional and might be nil.
|
||||
func (fs *FileSink) Sink(infoHash []byte, path string, peers []torrent.Peer) {
|
||||
if fs.terminated {
|
||||
zap.L().Panic("Trying to Sink() an already closed FileSink!")
|
||||
}
|
||||
go fs.awaitFile(&FileRequest{
|
||||
InfoHash: infoHash,
|
||||
Path: path,
|
||||
Peers: peers,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
func (fs *FileSink) Drain() <-chan File {
|
||||
func (fs *FileSink) Drain() <-chan FileResult {
|
||||
if fs.terminated {
|
||||
zap.L().Panic("Trying to Drain() an already closed FileSink!")
|
||||
}
|
||||
@ -98,7 +110,7 @@ func (fs *FileSink) Terminate() {
|
||||
}
|
||||
|
||||
|
||||
func (fs *FileSink) flush(result File) {
|
||||
func (fs *FileSink) flush(result FileResult) {
|
||||
if !fs.terminated {
|
||||
fs.drain <- result
|
||||
}
|
||||
|
@ -1,13 +1,11 @@
|
||||
package bittorrent
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"github.com/anacrolix/torrent"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
|
||||
"magneticod/dht/mainline"
|
||||
"persistence"
|
||||
)
|
||||
|
||||
|
||||
@ -19,7 +17,13 @@ type Metadata struct {
|
||||
TotalSize uint64
|
||||
DiscoveredOn int64
|
||||
// Files must be populated for both single-file and multi-file torrents!
|
||||
Files []metainfo.FileInfo
|
||||
Files []persistence.File
|
||||
// Peers is the list of the "active" peers at the time of fetching metadata. Currently, it's
|
||||
// always nil as anacrolix/torrent does not support returning list of peers for a given torrent,
|
||||
// but in the future, this information can be useful for the CompletingCoordinator which can use
|
||||
// those Peers to download the README file (if any found).
|
||||
Peers []torrent.Peer
|
||||
|
||||
}
|
||||
|
||||
|
||||
@ -31,11 +35,11 @@ type MetadataSink struct {
|
||||
}
|
||||
|
||||
|
||||
func NewMetadataSink(laddr net.TCPAddr) *MetadataSink {
|
||||
func NewMetadataSink(laddr string) *MetadataSink {
|
||||
ms := new(MetadataSink)
|
||||
var err error
|
||||
ms.client, err = torrent.NewClient(&torrent.Config{
|
||||
ListenAddr: laddr.String(),
|
||||
ListenAddr: laddr,
|
||||
DisableTrackers: true,
|
||||
DisablePEX: true,
|
||||
// TODO: Should we disable DHT to force the client to use the peers we supplied only, or not?
|
||||
|
111
src/magneticod/coordinators.go
Normal file
111
src/magneticod/coordinators.go
Normal file
@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/anacrolix/torrent"
|
||||
|
||||
"persistence"
|
||||
|
||||
"magneticod/bittorrent"
|
||||
|
||||
)
|
||||
|
||||
type completionRequest struct {
|
||||
infoHash []byte
|
||||
path string
|
||||
peers []torrent.Peer
|
||||
time time.Time
|
||||
}
|
||||
|
||||
type completionResult struct {
|
||||
InfoHash []byte
|
||||
Path string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
type CompletingCoordinator struct {
|
||||
database persistence.Database
|
||||
maxReadmeSize uint
|
||||
sink *bittorrent.FileSink
|
||||
queue chan completionRequest
|
||||
queueMutex sync.Mutex
|
||||
outputChan chan completionResult
|
||||
readmeRegex *regexp.Regexp
|
||||
terminated bool
|
||||
termination chan interface{}
|
||||
}
|
||||
|
||||
type CompletingCoordinatorOpFlags struct {
|
||||
LeechClAddr string
|
||||
LeechMlAddr string
|
||||
LeechTimeout time.Duration
|
||||
ReadmeMaxSize uint
|
||||
ReadmeRegex *regexp.Regexp
|
||||
}
|
||||
|
||||
func NewCompletingCoordinator(database persistence.Database, opFlags CompletingCoordinatorOpFlags) (cc *CompletingCoordinator) {
|
||||
cc = new(CompletingCoordinator)
|
||||
cc.database = database
|
||||
cc.maxReadmeSize = opFlags.ReadmeMaxSize
|
||||
cc.sink = bittorrent.NewFileSink(opFlags.LeechClAddr, opFlags.LeechMlAddr, opFlags.LeechTimeout)
|
||||
cc.queue = make(chan completionRequest, 100)
|
||||
cc.readmeRegex = opFlags.ReadmeRegex
|
||||
cc.termination = make(chan interface{})
|
||||
return
|
||||
}
|
||||
|
||||
func (cc *CompletingCoordinator) Request(infoHash []byte, path string, peers []torrent.Peer) {
|
||||
cc.queueMutex.Lock()
|
||||
defer cc.queueMutex.Unlock()
|
||||
|
||||
// If queue is full discard the oldest request as it is more likely to be outdated.
|
||||
if len(cc.queue) == cap(cc.queue) {
|
||||
<- cc.queue
|
||||
}
|
||||
|
||||
// Imagine, if this function [Request()] was called by another goroutine right when we were
|
||||
// here: the moment where we removed the oldest entry in the queue to free a single space for
|
||||
// the newest one. Imagine, now, that the second Request() call manages to add its own entry
|
||||
// to the queue, making the current goroutine wait until the cc.queue channel is available.
|
||||
//
|
||||
// Hence to prevent that we use cc.queueMutex
|
||||
|
||||
cc.queue <- completionRequest{
|
||||
infoHash: infoHash,
|
||||
path: path,
|
||||
peers: peers,
|
||||
time: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (cc *CompletingCoordinator) Start() {
|
||||
go cc.complete()
|
||||
}
|
||||
|
||||
func (cc *CompletingCoordinator) Output() <-chan completionResult {
|
||||
return cc.outputChan
|
||||
}
|
||||
|
||||
func (cc *CompletingCoordinator) complete() {
|
||||
for {
|
||||
select {
|
||||
case request := <-cc.queue:
|
||||
// Discard requests older than 2 minutes.
|
||||
// TODO: Instead of settling on 2 minutes as an arbitrary value, do some research to
|
||||
// learn average peer lifetime in the BitTorrent network.
|
||||
if time.Now().Sub(request.time) > 2 * time.Minute {
|
||||
continue
|
||||
}
|
||||
cc.sink.Sink(request.infoHash, request.path, request.peers)
|
||||
|
||||
case <-cc.termination:
|
||||
break
|
||||
|
||||
default:
|
||||
cc.database.FindAnIncompleteTorrent(cc.readmeRegex, cc.maxReadmeSize)
|
||||
}
|
||||
}
|
||||
}
|
59
src/magneticod/dht/mainline/bloomFilter.go
Normal file
59
src/magneticod/dht/mainline/bloomFilter.go
Normal file
@ -0,0 +1,59 @@
|
||||
package mainline
|
||||
|
||||
import (
|
||||
"net"
|
||||
"go.uber.org/zap"
|
||||
"crypto/sha1"
|
||||
"math/bits"
|
||||
"math"
|
||||
)
|
||||
|
||||
const (
|
||||
k uint32 = 2
|
||||
m uint32 = 256 * 8
|
||||
)
|
||||
|
||||
type BloomFilter struct {
|
||||
filter [m/8]byte
|
||||
}
|
||||
|
||||
func (bf *BloomFilter) InsertIP(ip net.IP) {
|
||||
if !(len(ip) == net.IPv4len || len(ip) == net.IPv6len) {
|
||||
zap.S().Panicf("Attempted to insert an invalid IP to the bloom filter! %d", len(ip))
|
||||
}
|
||||
|
||||
hash := sha1.Sum(ip)
|
||||
|
||||
var index1, index2 uint32
|
||||
index1 = uint32(hash[0]) | uint32(hash[1]) << 8
|
||||
index2 = uint32(hash[2]) | uint32(hash[3]) << 8
|
||||
|
||||
// truncate index to m (11 bits required)
|
||||
index1 %= m
|
||||
index2 %= m
|
||||
|
||||
// set bits at index1 and index2
|
||||
bf.filter[index1 / 8] |= 0x01 << (index1 % 8)
|
||||
bf.filter[index2 / 8] |= 0x01 << (index2 % 8)
|
||||
}
|
||||
|
||||
func (bf *BloomFilter) Estimate() float64 {
|
||||
// TODO: make it faster?
|
||||
var nZeroes uint32 = 0
|
||||
for _, b := range bf.filter {
|
||||
nZeroes += 8 - uint32(bits.OnesCount8(uint8(b)))
|
||||
}
|
||||
|
||||
var c uint32
|
||||
if m - 1 < nZeroes {
|
||||
c = m - 1
|
||||
} else {
|
||||
c = nZeroes
|
||||
}
|
||||
return math.Log(float64(c) / float64(m)) / (float64(k) * math.Log(1 - 1/float64(m)))
|
||||
}
|
||||
|
||||
func (bf *BloomFilter) Filter() (filterCopy [m/8]byte) {
|
||||
copy(filterCopy[:], bf.filter[:])
|
||||
return filterCopy
|
||||
}
|
64
src/magneticod/dht/mainline/bloomFilter_test.go
Normal file
64
src/magneticod/dht/mainline/bloomFilter_test.go
Normal file
@ -0,0 +1,64 @@
|
||||
package mainline
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func TestBEP33Filter(t *testing.T) {
|
||||
bf := new(BloomFilter)
|
||||
populateForBEP33(bf)
|
||||
|
||||
resultingFilter := bf.Filter()
|
||||
var expectedFilter [256]byte
|
||||
hex.Decode(expectedFilter[:], []byte(strings.Replace(
|
||||
"F6C3F5EA A07FFD91 BDE89F77 7F26FB2B FF37BDB8 FB2BBAA2 FD3DDDE7 BACFFF75 EE7CCBAE" +
|
||||
"FE5EEDB1 FBFAFF67 F6ABFF5E 43DDBCA3 FD9B9FFD F4FFD3E9 DFF12D1B DF59DB53 DBE9FA5B" +
|
||||
"7FF3B8FD FCDE1AFB 8BEDD7BE 2F3EE71E BBBFE93B CDEEFE14 8246C2BC 5DBFF7E7 EFDCF24F" +
|
||||
"D8DC7ADF FD8FFFDF DDFFF7A4 BBEEDF5C B95CE81F C7FCFF1F F4FFFFDF E5F7FDCB B7FD79B3" +
|
||||
"FA1FC77B FE07FFF9 05B7B7FF C7FEFEFF E0B8370B B0CD3F5B 7F2BD93F EB4386CF DD6F7FD5" +
|
||||
"BFAF2E9E BFFFFEEC D67ADBF7 C67F17EF D5D75EBA 6FFEBA7F FF47A91E B1BFBB53 E8ABFB57" +
|
||||
"62ABE8FF 237279BF EFBFEEF5 FFC5FEBF DFE5ADFF ADFEE1FB 737FFFFB FD9F6AEF FEEE76B6" +
|
||||
"FD8F72EF",
|
||||
" ", "", -1)))
|
||||
if !bytes.Equal(resultingFilter[:], expectedFilter[:]) {
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
func TestBEP33Estimation(t *testing.T) {
|
||||
bf := new(BloomFilter)
|
||||
populateForBEP33(bf)
|
||||
|
||||
// Because Go lacks a truncate function for floats...
|
||||
if fmt.Sprintf("%.5f", bf.Estimate())[:9] != "1224.9308" {
|
||||
t.Errorf("Expected 1224.9308 got %f instead!", bf.Estimate())
|
||||
}
|
||||
}
|
||||
|
||||
func populateForBEP33(bf *BloomFilter) {
|
||||
// 192.0.2.0 to 192.0.2.255 (both ranges inclusive)
|
||||
addr := []byte{192, 0, 2, 0}
|
||||
for i := 0; i <= 255; i++ {
|
||||
addr[3] = uint8(i)
|
||||
bf.InsertIP(addr)
|
||||
}
|
||||
|
||||
// 2001:DB8:: to 2001:DB8::3E7 (both ranges inclusive)
|
||||
addr = []byte{32, 1, 13, 184, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
|
||||
for i := 0; i <= 2; i++ {
|
||||
addr[14] = uint8(i)
|
||||
for e := 0; e <= 255; e++ {
|
||||
addr[15] = uint8(e)
|
||||
bf.InsertIP(addr)
|
||||
}
|
||||
}
|
||||
addr[14] = 3
|
||||
for e := 0; e <= 231; e++ {
|
||||
addr[15] = uint8(e)
|
||||
bf.InsertIP(addr)
|
||||
}
|
||||
}
|
@ -14,6 +14,7 @@ import (
|
||||
"github.com/anacrolix/torrent/bencode"
|
||||
"github.com/anacrolix/missinggo/iter"
|
||||
"regexp"
|
||||
"github.com/willf/bloom"
|
||||
)
|
||||
|
||||
|
||||
@ -46,6 +47,21 @@ type QueryArguments struct {
|
||||
Port int `bencode:"port,omitempty"`
|
||||
// Use senders apparent DHT port
|
||||
ImpliedPort int `bencode:"implied_port,omitempty"`
|
||||
|
||||
// Indicates whether the querying node is seeding the torrent it announces.
|
||||
// Defined in BEP 33 "DHT Scrapes" for `announce_peer` queries.
|
||||
Seed int `bencode:"seed,omitempty"`
|
||||
|
||||
// If 1, then the responding node should try to fill the `values` list with non-seed items on a
|
||||
// best-effort basis."
|
||||
// Defined in BEP 33 "DHT Scrapes" for `get_peers` queries.
|
||||
NoSeed int `bencode:"noseed,omitempty"`
|
||||
// If 1, then the responding node should add two fields to the "r" dictionary in the response:
|
||||
// - `BFsd`: Bloom Filter (256 bytes) representing all stored seeds for that infohash
|
||||
// - `BFpe`: Bloom Filter (256 bytes) representing all stored peers (leeches) for that
|
||||
// infohash
|
||||
// Defined in BEP 33 "DHT Scrapes" for `get_peers` queries.
|
||||
Scrape int `bencode:"noseed,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@ -58,6 +74,15 @@ type ResponseValues struct {
|
||||
Token []byte `bencode:"token,omitempty"`
|
||||
// Torrent peers
|
||||
Values []CompactPeer `bencode:"values,omitempty"`
|
||||
|
||||
// If `scrape` is set to 1 in the `get_peers` query then the responding node should add the
|
||||
// below two fields to the "r" dictionary in the response:
|
||||
// Defined in BEP 33 "DHT Scrapes" for responses to `get_peers` queries.
|
||||
// Bloom Filter (256 bytes) representing all stored seeds for that infohash:
|
||||
BFsd *bloom.BloomFilter `bencode:"BFsd,omitempty"`
|
||||
// Bloom Filter (256 bytes) representing all stored peers (leeches) for that infohash:
|
||||
BFpe *bloom.BloomFilter `bencode:"BFpe,omitempty"`
|
||||
// TODO: write marshallers for those fields above ^^
|
||||
}
|
||||
|
||||
|
||||
@ -204,7 +229,7 @@ func (cni *CompactNodeInfo) UnmarshalBinary(b []byte) error {
|
||||
|
||||
|
||||
func (cnis CompactNodeInfos) MarshalBencode() ([]byte, error) {
|
||||
ret := make([]byte, 0) // TODO: this doesn't look idiomatic at all, is this the right way?
|
||||
var ret []byte
|
||||
|
||||
for _, cni := range cnis {
|
||||
ret = append(ret, cni.MarshalBinary()...)
|
||||
|
@ -31,7 +31,7 @@ type ProtocolEventHandlers struct {
|
||||
}
|
||||
|
||||
|
||||
func NewProtocol(laddr net.UDPAddr, eventHandlers ProtocolEventHandlers) (p *Protocol) {
|
||||
func NewProtocol(laddr string, eventHandlers ProtocolEventHandlers) (p *Protocol) {
|
||||
p = new(Protocol)
|
||||
p.transport = NewTransport(laddr, p.onMessage)
|
||||
p.eventHandlers = eventHandlers
|
||||
|
@ -38,7 +38,7 @@ type TrawlingServiceEventHandlers struct {
|
||||
}
|
||||
|
||||
|
||||
func NewTrawlingService(laddr net.UDPAddr, eventHandlers TrawlingServiceEventHandlers) *TrawlingService {
|
||||
func NewTrawlingService(laddr string, eventHandlers TrawlingServiceEventHandlers) *TrawlingService {
|
||||
service := new(TrawlingService)
|
||||
service.protocol = NewProtocol(
|
||||
laddr,
|
||||
|
@ -10,8 +10,8 @@ import (
|
||||
|
||||
|
||||
type Transport struct {
|
||||
conn *net.UDPConn
|
||||
laddr net.UDPAddr
|
||||
conn *net.UDPConn
|
||||
laddr *net.UDPAddr
|
||||
started bool
|
||||
|
||||
// OnMessage is the function that will be called when Transport receives a packet that is
|
||||
@ -21,10 +21,13 @@ type Transport struct {
|
||||
}
|
||||
|
||||
|
||||
func NewTransport(laddr net.UDPAddr, onMessage func(*Message, net.Addr)) (*Transport) {
|
||||
func NewTransport(laddr string, onMessage func(*Message, net.Addr)) (*Transport) {
|
||||
transport := new(Transport)
|
||||
transport.onMessage = onMessage
|
||||
transport.laddr = laddr
|
||||
var err error; transport.laddr, err = net.ResolveUDPAddr("udp", laddr)
|
||||
if err != nil {
|
||||
zap.L().Panic("Could not resolve the UDP address for the trawler!", zap.Error(err))
|
||||
}
|
||||
|
||||
return transport
|
||||
}
|
||||
@ -45,7 +48,7 @@ func (t *Transport) Start() {
|
||||
t.started = true
|
||||
|
||||
var err error
|
||||
t.conn, err = net.ListenUDP("udp", &t.laddr)
|
||||
t.conn, err = net.ListenUDP("udp", t.laddr)
|
||||
if err != nil {
|
||||
zap.L().Fatal("Could NOT create a UDP socket!", zap.Error(err))
|
||||
}
|
||||
|
@ -1,10 +1,6 @@
|
||||
package dht
|
||||
|
||||
import (
|
||||
"magneticod/dht/mainline"
|
||||
"net"
|
||||
"github.com/bradfitz/iter"
|
||||
)
|
||||
import "magneticod/dht/mainline"
|
||||
|
||||
|
||||
type TrawlingManager struct {
|
||||
@ -14,29 +10,20 @@ type TrawlingManager struct {
|
||||
}
|
||||
|
||||
|
||||
func NewTrawlingManager(mlAddrs []net.UDPAddr) *TrawlingManager {
|
||||
func NewTrawlingManager(mlAddrs []string) *TrawlingManager {
|
||||
manager := new(TrawlingManager)
|
||||
manager.output = make(chan mainline.TrawlingResult)
|
||||
|
||||
if mlAddrs != nil {
|
||||
for _, addr := range mlAddrs {
|
||||
manager.services = append(manager.services, mainline.NewTrawlingService(
|
||||
addr,
|
||||
mainline.TrawlingServiceEventHandlers{
|
||||
OnResult: manager.onResult,
|
||||
},
|
||||
))
|
||||
}
|
||||
} else {
|
||||
addr := net.UDPAddr{IP: []byte("\x00\x00\x00\x00"), Port: 0}
|
||||
for range iter.N(1) {
|
||||
manager.services = append(manager.services, mainline.NewTrawlingService(
|
||||
addr,
|
||||
mainline.TrawlingServiceEventHandlers{
|
||||
OnResult: manager.onResult,
|
||||
},
|
||||
))
|
||||
}
|
||||
if mlAddrs == nil {
|
||||
mlAddrs = []string{"0.0.0.0:0"}
|
||||
}
|
||||
for _, addr := range mlAddrs {
|
||||
manager.services = append(manager.services, mainline.NewTrawlingService(
|
||||
addr,
|
||||
mainline.TrawlingServiceEventHandlers{
|
||||
OnResult: manager.onResult,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
for _, service := range manager.services {
|
||||
|
@ -1,20 +1,23 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/jessevdk/go-flags"
|
||||
"github.com/pkg/profile"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
|
||||
"persistence"
|
||||
|
||||
"magneticod/bittorrent"
|
||||
"magneticod/dht"
|
||||
"fmt"
|
||||
"time"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
)
|
||||
|
||||
type cmdFlags struct {
|
||||
@ -35,14 +38,13 @@ type cmdFlags struct {
|
||||
LeechMlAddr string `long:"leech-ml-addr" descrition:"Address to be used by the mainline DHT node for fetching README files." default:"0.0.0.0:0"`
|
||||
LeechTimeout uint `long:"leech-timeout" description:"Number of integer seconds to pass before a leech timeouts." default:"300"`
|
||||
ReadmeMaxSize uint `long:"readme-max-size" description:"Maximum size -which must be greater than zero- of a description file in bytes." default:"20480"`
|
||||
ReadmeRegexes []string `long:"readme-regex" description:"Regular expression(s) which will be tested against the name of the README files, in the supplied order."`
|
||||
ReadmeRegex string `long:"readme-regex" description:"Regular expression(s) which will be tested against the name of the README files, in the supplied order."`
|
||||
|
||||
Verbose []bool `short:"v" long:"verbose" description:"Increases verbosity."`
|
||||
|
||||
Profile string `long:"profile" description:"Enable profiling." default:""`
|
||||
|
||||
// ==== Deprecated Flags ====
|
||||
// TODO: don't even support deprecated flags!
|
||||
// ==== OLD Flags ====
|
||||
|
||||
// DatabaseFile is akin to Database flag, except that it was used when SQLite was the only
|
||||
// persistence backend ever conceived, so it's the path* to the database file, which was -by
|
||||
@ -53,7 +55,6 @@ type cmdFlags struct {
|
||||
// On BSDs? : TODO?
|
||||
// On anywhere else: TODO?
|
||||
// TODO: Is the path* absolute or can be relative as well?
|
||||
// DatabaseFile string
|
||||
}
|
||||
|
||||
const (
|
||||
@ -70,19 +71,17 @@ type opFlags struct {
|
||||
TrawlerMlAddrs []string
|
||||
TrawlerMlInterval time.Duration
|
||||
|
||||
// TODO: is this even supported by anacrolix/torrent?
|
||||
FetcherAddr string
|
||||
FetcherTimeout time.Duration
|
||||
|
||||
StatistMlAddrs []string
|
||||
StatistMlTimeout time.Duration
|
||||
|
||||
// TODO: is this even supported by anacrolix/torrent?
|
||||
LeechClAddr string
|
||||
LeechMlAddr string
|
||||
LeechTimeout time.Duration
|
||||
ReadmeMaxSize uint
|
||||
ReadmeRegexes []*regexp.Regexp
|
||||
ReadmeRegex *regexp.Regexp
|
||||
|
||||
Verbosity int
|
||||
|
||||
@ -90,12 +89,12 @@ type opFlags struct {
|
||||
}
|
||||
|
||||
func main() {
|
||||
atom := zap.NewAtomicLevel()
|
||||
loggerLevel := zap.NewAtomicLevel()
|
||||
// Logging levels: ("debug", "info", "warn", "error", "dpanic", "panic", and "fatal").
|
||||
logger := zap.New(zapcore.NewCore(
|
||||
zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig()),
|
||||
zapcore.Lock(os.Stderr),
|
||||
atom,
|
||||
loggerLevel,
|
||||
))
|
||||
defer logger.Sync()
|
||||
zap.ReplaceGlobals(logger)
|
||||
@ -111,81 +110,70 @@ func main() {
|
||||
|
||||
switch opFlags.Verbosity {
|
||||
case 0:
|
||||
atom.SetLevel(zap.WarnLevel)
|
||||
loggerLevel.SetLevel(zap.WarnLevel)
|
||||
case 1:
|
||||
atom.SetLevel(zap.InfoLevel)
|
||||
// Default: i.e. in case of 2 or more.
|
||||
loggerLevel.SetLevel(zap.InfoLevel)
|
||||
// Default: i.e. in case of 2 or more.
|
||||
default:
|
||||
atom.SetLevel(zap.DebugLevel)
|
||||
loggerLevel.SetLevel(zap.DebugLevel)
|
||||
}
|
||||
|
||||
zap.ReplaceGlobals(logger)
|
||||
|
||||
/*
|
||||
updating_manager := nil
|
||||
statistics_sink := nil
|
||||
completing_manager := nil
|
||||
file_sink := nil
|
||||
*/
|
||||
// Handle Ctrl-C gracefully.
|
||||
interrupt_chan := make(chan os.Signal)
|
||||
signal.Notify(interrupt_chan, os.Interrupt)
|
||||
interruptChan := make(chan os.Signal)
|
||||
signal.Notify(interruptChan, os.Interrupt)
|
||||
|
||||
database, err := NewDatabase(opFlags.Database)
|
||||
database, err := persistence.MakeDatabase(opFlags.DatabaseURL)
|
||||
if err != nil {
|
||||
logger.Sugar().Fatalf("Could not open the database at `%s`: %s", opFlags.Database, err.Error())
|
||||
logger.Sugar().Fatalf("Could not open the database at `%s`: %s", opFlags.DatabaseURL, err.Error())
|
||||
}
|
||||
|
||||
trawlingManager := dht.NewTrawlingManager(opFlags.MlTrawlerAddrs)
|
||||
metadataSink := bittorrent.NewMetadataSink(opFlags.FetcherAddr)
|
||||
fileSink := bittorrent.NewFileSink()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case result := <-trawlingManager.Output():
|
||||
logger.Debug("result: ", zap.String("hash", result.InfoHash.String()))
|
||||
if !database.DoesExist(result.InfoHash[:]) {
|
||||
metadataSink.Sink(result)
|
||||
}
|
||||
|
||||
case metadata := <-metadataSink.Drain():
|
||||
logger.Sugar().Infof("D I S C O V E R E D: `%s` %x",
|
||||
metadata.Name, metadata.InfoHash)
|
||||
if err := database.AddNewTorrent(metadata); err != nil {
|
||||
logger.Sugar().Fatalf("Could not add new torrent %x to the database: %s",
|
||||
metadata.InfoHash, err.Error())
|
||||
}
|
||||
|
||||
case <-interrupt_chan:
|
||||
trawlingManager.Terminate()
|
||||
break
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
|
||||
}()
|
||||
|
||||
go func() {
|
||||
|
||||
}()
|
||||
|
||||
trawlingManager := dht.NewTrawlingManager(opFlags.TrawlerMlAddrs)
|
||||
metadataSink := bittorrent.NewMetadataSink(opFlags.FetcherAddr)
|
||||
completingCoordinator := NewCompletingCoordinator(database, CompletingCoordinatorOpFlags{
|
||||
LeechClAddr: opFlags.LeechClAddr,
|
||||
LeechMlAddr: opFlags.LeechMlAddr,
|
||||
LeechTimeout: opFlags.LeechTimeout,
|
||||
ReadmeMaxSize: opFlags.ReadmeMaxSize,
|
||||
ReadmeRegex: opFlags.ReadmeRegex,
|
||||
})
|
||||
/*
|
||||
for {
|
||||
select {
|
||||
refreshingCoordinator := NewRefreshingCoordinator(database, RefreshingCoordinatorOpFlags{
|
||||
|
||||
case updating_manager.Output():
|
||||
|
||||
case statistics_sink.Sink():
|
||||
|
||||
case completing_manager.Output():
|
||||
|
||||
case file_sink.Sink():
|
||||
})
|
||||
*/
|
||||
|
||||
<-interrupt_chan
|
||||
for {
|
||||
select {
|
||||
case result := <-trawlingManager.Output():
|
||||
logger.Debug("result: ", zap.String("hash", result.InfoHash.String()))
|
||||
exists, err := database.DoesTorrentExist(result.InfoHash[:])
|
||||
if err != nil {
|
||||
zap.L().Fatal("Could not check whether torrent exists!", zap.Error(err))
|
||||
} else if !exists {
|
||||
metadataSink.Sink(result)
|
||||
}
|
||||
|
||||
case metadata := <-metadataSink.Drain():
|
||||
if err := database.AddNewTorrent(metadata.InfoHash, metadata.Name, metadata.Files); err != nil {
|
||||
logger.Sugar().Fatalf("Could not add new torrent %x to the database: %s",
|
||||
metadata.InfoHash, err.Error())
|
||||
}
|
||||
logger.Sugar().Infof("D I S C O V E R E D: `%s` %x", metadata.Name, metadata.InfoHash)
|
||||
|
||||
if readmePath := findReadme(opFlags.ReadmeRegex, metadata.Files); readmePath != nil {
|
||||
completingCoordinator.Request(metadata.InfoHash, *readmePath, metadata.Peers)
|
||||
}
|
||||
|
||||
case result := <-completingCoordinator.Output():
|
||||
database.AddReadme(result.InfoHash, result.Path, result.Data)
|
||||
|
||||
case <-interruptChan:
|
||||
trawlingManager.Terminate()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseFlags() (opF opFlags) {
|
||||
@ -237,13 +225,13 @@ func parseFlags() (opF opFlags) {
|
||||
}
|
||||
|
||||
if err = checkAddrs([]string{cmdF.LeechClAddr}); err != nil {
|
||||
zap.S().Fatal("Of argument `leech-cl-addr` %s", err.Error())
|
||||
zap.S().Fatalf("Of argument `leech-cl-addr` %s", err.Error())
|
||||
} else {
|
||||
opF.LeechClAddr = cmdF.LeechClAddr
|
||||
}
|
||||
|
||||
if err = checkAddrs([]string{cmdF.LeechMlAddr}); err != nil {
|
||||
zap.S().Fatal("Of argument `leech-ml-addr` %s", err.Error())
|
||||
zap.S().Fatalf("Of argument `leech-ml-addr` %s", err.Error())
|
||||
} else {
|
||||
opF.LeechMlAddr = cmdF.LeechMlAddr
|
||||
}
|
||||
@ -260,13 +248,9 @@ func parseFlags() (opF opFlags) {
|
||||
opF.ReadmeMaxSize = cmdF.ReadmeMaxSize
|
||||
}
|
||||
|
||||
for i, s := range cmdF.ReadmeRegexes {
|
||||
regex, err := regexp.Compile(s)
|
||||
if err != nil {
|
||||
zap.S().Fatalf("Of argument `readme-regex` with %d(th) regex `%s`: %s", i + 1, s, err.Error())
|
||||
} else {
|
||||
opF.ReadmeRegexes = append(opF.ReadmeRegexes, regex)
|
||||
}
|
||||
opF.ReadmeRegex, err = regexp.Compile(cmdF.ReadmeRegex)
|
||||
if err != nil {
|
||||
zap.S().Fatalf("Argument `readme-regex` is not a valid regex: %s", err.Error())
|
||||
}
|
||||
|
||||
opF.Verbosity = len(cmdF.Verbose)
|
||||
@ -286,3 +270,15 @@ func checkAddrs(addrs []string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// findReadme looks for a possible Readme file whose path is matched by the pathRegex.
|
||||
// If there are multiple matches, the first one is returned.
|
||||
// If there are no matches, nil returned.
|
||||
func findReadme(pathRegex *regexp.Regexp, files []persistence.File) *string {
|
||||
for _, file := range files {
|
||||
if pathRegex.MatchString(file.Path) {
|
||||
return &file.Path
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
package main
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@ -13,6 +13,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"magneticod/bittorrent"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
type engineType uint8
|
||||
@ -78,7 +79,7 @@ func NewDatabase(rawurl string) (*Database, error) {
|
||||
}
|
||||
|
||||
|
||||
func (db *Database) DoesExist(infoHash []byte) bool {
|
||||
func (db *Database) DoesTorrentExist(infoHash []byte) bool {
|
||||
for _, torrent := range db.newTorrents {
|
||||
if bytes.Equal(infoHash, torrent.InfoHash) {
|
||||
return true;
|
||||
@ -96,6 +97,30 @@ func (db *Database) DoesExist(infoHash []byte) bool {
|
||||
return rows.Next()
|
||||
}
|
||||
|
||||
func (db *Database) FindAnIncompleteTorrent(pathRegex *regexp.Regexp, maxSize uint) error {
|
||||
switch db.engine {
|
||||
case SQLITE:
|
||||
return db.findAnIncompleteTorrent_SQLite(pathRegex, maxSize)
|
||||
|
||||
default:
|
||||
zap.L().Fatal("Unknown database engine!", zap.Uint8("engine", uint8(db.engine)))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (db *Database) findAnIncompleteTorrent_SQLite(pathRegex *regexp.Regexp, maxSize uint) error {
|
||||
// TODO: Prefer torrents with most seeders & leechs (i.e. most popular)
|
||||
_, err := db.database.Query(`
|
||||
SELECT torrents.info_hash, files.path FROM files WHERE files.path REGEXP ?
|
||||
INNER JOIN torrents ON files.torrent_id = torrents.id LIMIT 1;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// AddNewTorrent adds a new torrent to the *queue* to be flushed to the persistent database.
|
||||
func (db *Database) AddNewTorrent(torrent bittorrent.Metadata) error {
|
||||
@ -107,7 +132,7 @@ func (db *Database) AddNewTorrent(torrent bittorrent.Metadata) error {
|
||||
// that it doesn't exists there, add it to the sink.
|
||||
// Hence check for the last time whether the torrent exists in the database, and only if not,
|
||||
// add it.
|
||||
if db.DoesExist(torrent.InfoHash) {
|
||||
if db.DoesTorrentExist(torrent.InfoHash) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
@ -125,8 +150,14 @@ func (db *Database) AddNewTorrent(torrent bittorrent.Metadata) error {
|
||||
}
|
||||
|
||||
|
||||
func (db *Database) AddReadme(infoHash []byte, path string, data []byte) error {
|
||||
// TODO
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
func (db *Database) commitNewTorrents() error {
|
||||
tx, err := db.database.Begin()
|
||||
tx, err := db.database.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("sql.DB.Begin()! %s", err.Error())
|
||||
}
|
||||
@ -216,11 +247,11 @@ func setupSqliteDatabase(database *sql.DB) error {
|
||||
//
|
||||
// Enable foreign key constraints in SQLite which are crucial to prevent programmer errors on
|
||||
// our side.
|
||||
_, err := database.Exec(
|
||||
`PRAGMA journal_mode=WAL;
|
||||
_, err := database.Exec(`
|
||||
PRAGMA journal_mode=WAL;
|
||||
PRAGMA temp_store=1;
|
||||
PRAGMA foreign_keys=ON;`,
|
||||
)
|
||||
PRAGMA foreign_keys=ON;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -231,33 +262,28 @@ func setupSqliteDatabase(database *sql.DB) error {
|
||||
}
|
||||
|
||||
// Essential, and valid for all user_version`s:
|
||||
_, err = tx.Exec(
|
||||
`CREATE TABLE IF NOT EXISTS torrents (
|
||||
// TODO: "torrent_id" column of the "files" table can be NULL, how can we fix this in a new schema?
|
||||
_, err = tx.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS torrents (
|
||||
id INTEGER PRIMARY KEY,
|
||||
info_hash BLOB NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
total_size INTEGER NOT NULL CHECK(total_size > 0),
|
||||
discovered_on INTEGER NOT NULL CHECK(discovered_on > 0)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS info_hash_index ON torrents (info_hash);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id INTEGER PRIMARY KEY,
|
||||
torrent_id INTEGER REFERENCES torrents ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
size INTEGER NOT NULL,
|
||||
path TEXT NOT NULL
|
||||
);
|
||||
`,
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the user_version:
|
||||
res, err := tx.Query(
|
||||
`PRAGMA user_version;`,
|
||||
)
|
||||
res, err := tx.Query(`PRAGMA user_version;`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -265,18 +291,57 @@ func setupSqliteDatabase(database *sql.DB) error {
|
||||
res.Next()
|
||||
res.Scan(&userVersion)
|
||||
|
||||
// Upgrade to the latest schema:
|
||||
switch userVersion {
|
||||
// Upgrade from user_version 0 to 1
|
||||
// The Change:
|
||||
// * `info_hash_index` is recreated as UNIQUE.
|
||||
case 0:
|
||||
_, err = tx.Exec(
|
||||
`ALTER TABLE torrents ADD COLUMN readme TEXT;
|
||||
PRAGMA user_version = 1;`,
|
||||
)
|
||||
zap.S().Warnf("Updating database schema from 0 to 1... (this might take a while)")
|
||||
_, err = tx.Exec(`
|
||||
DROP INDEX info_hash_index;
|
||||
CREATE UNIQUE INDEX info_hash_index ON torrents (info_hash);
|
||||
PRAGMA user_version = 1;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fallthrough
|
||||
// Upgrade from user_version 1 to 2
|
||||
// The Change:
|
||||
// * Added `is_readme` and `content` columns to the `files` table, and the constraints & the
|
||||
// the indices they entail.
|
||||
// * Added unique index `readme_index` on `files` table.
|
||||
case 1:
|
||||
zap.S().Warnf("Updating database schema from 1 to 2... (this might take a while)")
|
||||
// We introduce two new columns here: content BLOB, and is_readme INTEGER which we treat as
|
||||
// a bool (hence the CHECK).
|
||||
// The reason for the change is that as we introduce the new "readme" feature which
|
||||
// downloads a readme file as a torrent descriptor, we needed to store it somewhere in the
|
||||
// database with the following conditions:
|
||||
//
|
||||
// 1. There can be one and only one readme (content) for a given torrent; hence the
|
||||
// UNIQUE INDEX on (torrent_id, is_description) (remember that SQLite treats each NULL
|
||||
// value as distinct [UNIQUE], see https://sqlite.org/nulls.html).
|
||||
// 2. We would like to keep the readme (content) associated with the file it came from;
|
||||
// hence we modify the files table instead of the torrents table.
|
||||
//
|
||||
// Regarding the implementation details, following constraints arise:
|
||||
//
|
||||
// 1. The column is_readme is either NULL or 1, and if it is 1, then content column cannot
|
||||
// be NULL (but might be an empty BLOB). Vice versa, if content column of a row is,
|
||||
// NULL then is_readme must be NULL.
|
||||
//
|
||||
// This is to prevent unused content fields filling up the database, and to catch
|
||||
// programmers' errors.
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE files ADD COLUMN is_readme INTEGER CHECK (is_readme IS NULL OR is_readme=1);
|
||||
ALTER TABLE files ADD COLUMN content BLOB CHECK((content IS NULL AND is_readme IS NULL) OR (content IS NOT NULL AND is_readme=1));
|
||||
CREATE UNIQUE INDEX readme_index ON files (torrent_id, is_readme);
|
||||
PRAGMA user_version = 2;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Add `fallthrough`s as needed to keep upgrading...
|
||||
}
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
@ -1,4 +1,4 @@
|
||||
package main
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"path"
|
262
src/magneticow/bindata.go
Normal file
262
src/magneticow/bindata.go
Normal file
@ -0,0 +1,262 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
)
|
||||
|
||||
// bindata_read reads the given file from disk. It returns
|
||||
// an error on failure.
|
||||
func bindata_read(path, name string) ([]byte, error) {
|
||||
buf, err := ioutil.ReadFile(path)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("Error reading asset %s at %s: %v", name, path, err)
|
||||
}
|
||||
return buf, err
|
||||
}
|
||||
|
||||
|
||||
// templates_torrent_html reads file data from disk.
|
||||
// It panics if something went wrong in the process.
|
||||
func templates_torrent_html() ([]byte, error) {
|
||||
return bindata_read(
|
||||
"/home/bora/labs/magnetico/src/magneticow/data/templates/torrent.html",
|
||||
"templates/torrent.html",
|
||||
)
|
||||
}
|
||||
|
||||
// templates_feed_xml reads file data from disk.
|
||||
// It panics if something went wrong in the process.
|
||||
func templates_feed_xml() ([]byte, error) {
|
||||
return bindata_read(
|
||||
"/home/bora/labs/magnetico/src/magneticow/data/templates/feed.xml",
|
||||
"templates/feed.xml",
|
||||
)
|
||||
}
|
||||
|
||||
// templates_homepage_html reads file data from disk.
|
||||
// It panics if something went wrong in the process.
|
||||
func templates_homepage_html() ([]byte, error) {
|
||||
return bindata_read(
|
||||
"/home/bora/labs/magnetico/src/magneticow/data/templates/homepage.html",
|
||||
"templates/homepage.html",
|
||||
)
|
||||
}
|
||||
|
||||
// templates_statistics_html reads file data from disk.
|
||||
// It panics if something went wrong in the process.
|
||||
func templates_statistics_html() ([]byte, error) {
|
||||
return bindata_read(
|
||||
"/home/bora/labs/magnetico/src/magneticow/data/templates/statistics.html",
|
||||
"templates/statistics.html",
|
||||
)
|
||||
}
|
||||
|
||||
// templates_torrents_html reads file data from disk.
|
||||
// It panics if something went wrong in the process.
|
||||
func templates_torrents_html() ([]byte, error) {
|
||||
return bindata_read(
|
||||
"/home/bora/labs/magnetico/src/magneticow/data/templates/torrents.html",
|
||||
"templates/torrents.html",
|
||||
)
|
||||
}
|
||||
|
||||
// static_scripts_plotly_v1_26_1_min_js reads file data from disk.
|
||||
// It panics if something went wrong in the process.
|
||||
func static_scripts_plotly_v1_26_1_min_js() ([]byte, error) {
|
||||
return bindata_read(
|
||||
"/home/bora/labs/magnetico/src/magneticow/data/static/scripts/plotly-v1.26.1.min.js",
|
||||
"static/scripts/plotly-v1.26.1.min.js",
|
||||
)
|
||||
}
|
||||
|
||||
// static_scripts_statistics_js reads file data from disk.
|
||||
// It panics if something went wrong in the process.
|
||||
func static_scripts_statistics_js() ([]byte, error) {
|
||||
return bindata_read(
|
||||
"/home/bora/labs/magnetico/src/magneticow/data/static/scripts/statistics.js",
|
||||
"static/scripts/statistics.js",
|
||||
)
|
||||
}
|
||||
|
||||
// static_scripts_torrent_js reads file data from disk.
|
||||
// It panics if something went wrong in the process.
|
||||
func static_scripts_torrent_js() ([]byte, error) {
|
||||
return bindata_read(
|
||||
"/home/bora/labs/magnetico/src/magneticow/data/static/scripts/torrent.js",
|
||||
"static/scripts/torrent.js",
|
||||
)
|
||||
}
|
||||
|
||||
// static_styles_reset_css reads file data from disk.
|
||||
// It panics if something went wrong in the process.
|
||||
func static_styles_reset_css() ([]byte, error) {
|
||||
return bindata_read(
|
||||
"/home/bora/labs/magnetico/src/magneticow/data/static/styles/reset.css",
|
||||
"static/styles/reset.css",
|
||||
)
|
||||
}
|
||||
|
||||
// static_styles_statistics_css reads file data from disk.
|
||||
// It panics if something went wrong in the process.
|
||||
func static_styles_statistics_css() ([]byte, error) {
|
||||
return bindata_read(
|
||||
"/home/bora/labs/magnetico/src/magneticow/data/static/styles/statistics.css",
|
||||
"static/styles/statistics.css",
|
||||
)
|
||||
}
|
||||
|
||||
// static_styles_torrent_css reads file data from disk.
|
||||
// It panics if something went wrong in the process.
|
||||
func static_styles_torrent_css() ([]byte, error) {
|
||||
return bindata_read(
|
||||
"/home/bora/labs/magnetico/src/magneticow/data/static/styles/torrent.css",
|
||||
"static/styles/torrent.css",
|
||||
)
|
||||
}
|
||||
|
||||
// static_styles_torrents_css reads file data from disk.
|
||||
// It panics if something went wrong in the process.
|
||||
func static_styles_torrents_css() ([]byte, error) {
|
||||
return bindata_read(
|
||||
"/home/bora/labs/magnetico/src/magneticow/data/static/styles/torrents.css",
|
||||
"static/styles/torrents.css",
|
||||
)
|
||||
}
|
||||
|
||||
// static_styles_homepage_css reads file data from disk.
|
||||
// It panics if something went wrong in the process.
|
||||
func static_styles_homepage_css() ([]byte, error) {
|
||||
return bindata_read(
|
||||
"/home/bora/labs/magnetico/src/magneticow/data/static/styles/homepage.css",
|
||||
"static/styles/homepage.css",
|
||||
)
|
||||
}
|
||||
|
||||
// static_styles_essential_css reads file data from disk.
|
||||
// It panics if something went wrong in the process.
|
||||
func static_styles_essential_css() ([]byte, error) {
|
||||
return bindata_read(
|
||||
"/home/bora/labs/magnetico/src/magneticow/data/static/styles/essential.css",
|
||||
"static/styles/essential.css",
|
||||
)
|
||||
}
|
||||
|
||||
// static_assets_magnet_gif reads file data from disk.
|
||||
// It panics if something went wrong in the process.
|
||||
func static_assets_magnet_gif() ([]byte, error) {
|
||||
return bindata_read(
|
||||
"/home/bora/labs/magnetico/src/magneticow/data/static/assets/magnet.gif",
|
||||
"static/assets/magnet.gif",
|
||||
)
|
||||
}
|
||||
|
||||
// static_assets_feed_png reads file data from disk.
|
||||
// It panics if something went wrong in the process.
|
||||
func static_assets_feed_png() ([]byte, error) {
|
||||
return bindata_read(
|
||||
"/home/bora/labs/magnetico/src/magneticow/data/static/assets/feed.png",
|
||||
"static/assets/feed.png",
|
||||
)
|
||||
}
|
||||
|
||||
// static_fonts_notomono_license_ofl_txt reads file data from disk.
|
||||
// It panics if something went wrong in the process.
|
||||
func static_fonts_notomono_license_ofl_txt() ([]byte, error) {
|
||||
return bindata_read(
|
||||
"/home/bora/labs/magnetico/src/magneticow/data/static/fonts/NotoMono/LICENSE_OFL.txt",
|
||||
"static/fonts/NotoMono/LICENSE_OFL.txt",
|
||||
)
|
||||
}
|
||||
|
||||
// static_fonts_notomono_regular_ttf reads file data from disk.
|
||||
// It panics if something went wrong in the process.
|
||||
func static_fonts_notomono_regular_ttf() ([]byte, error) {
|
||||
return bindata_read(
|
||||
"/home/bora/labs/magnetico/src/magneticow/data/static/fonts/NotoMono/Regular.ttf",
|
||||
"static/fonts/NotoMono/Regular.ttf",
|
||||
)
|
||||
}
|
||||
|
||||
// static_fonts_notosansui_license_ofl_txt reads file data from disk.
|
||||
// It panics if something went wrong in the process.
|
||||
func static_fonts_notosansui_license_ofl_txt() ([]byte, error) {
|
||||
return bindata_read(
|
||||
"/home/bora/labs/magnetico/src/magneticow/data/static/fonts/NotoSansUI/LICENSE_OFL.txt",
|
||||
"static/fonts/NotoSansUI/LICENSE_OFL.txt",
|
||||
)
|
||||
}
|
||||
|
||||
// static_fonts_notosansui_bold_ttf reads file data from disk.
|
||||
// It panics if something went wrong in the process.
|
||||
func static_fonts_notosansui_bold_ttf() ([]byte, error) {
|
||||
return bindata_read(
|
||||
"/home/bora/labs/magnetico/src/magneticow/data/static/fonts/NotoSansUI/Bold.ttf",
|
||||
"static/fonts/NotoSansUI/Bold.ttf",
|
||||
)
|
||||
}
|
||||
|
||||
// static_fonts_notosansui_bolditalic_ttf reads file data from disk.
|
||||
// It panics if something went wrong in the process.
|
||||
func static_fonts_notosansui_bolditalic_ttf() ([]byte, error) {
|
||||
return bindata_read(
|
||||
"/home/bora/labs/magnetico/src/magneticow/data/static/fonts/NotoSansUI/BoldItalic.ttf",
|
||||
"static/fonts/NotoSansUI/BoldItalic.ttf",
|
||||
)
|
||||
}
|
||||
|
||||
// static_fonts_notosansui_italic_ttf reads file data from disk.
|
||||
// It panics if something went wrong in the process.
|
||||
func static_fonts_notosansui_italic_ttf() ([]byte, error) {
|
||||
return bindata_read(
|
||||
"/home/bora/labs/magnetico/src/magneticow/data/static/fonts/NotoSansUI/Italic.ttf",
|
||||
"static/fonts/NotoSansUI/Italic.ttf",
|
||||
)
|
||||
}
|
||||
|
||||
// static_fonts_notosansui_regular_ttf reads file data from disk.
|
||||
// It panics if something went wrong in the process.
|
||||
func static_fonts_notosansui_regular_ttf() ([]byte, error) {
|
||||
return bindata_read(
|
||||
"/home/bora/labs/magnetico/src/magneticow/data/static/fonts/NotoSansUI/Regular.ttf",
|
||||
"static/fonts/NotoSansUI/Regular.ttf",
|
||||
)
|
||||
}
|
||||
|
||||
// Asset loads and returns the asset for the given name.
|
||||
// It returns an error if the asset could not be found or
|
||||
// could not be loaded.
|
||||
func Asset(name string) ([]byte, error) {
|
||||
if f, ok := _bindata[name]; ok {
|
||||
return f()
|
||||
}
|
||||
return nil, fmt.Errorf("Asset %s not found", name)
|
||||
}
|
||||
|
||||
// _bindata is a table, holding each asset generator, mapped to its name.
|
||||
var _bindata = map[string] func() ([]byte, error) {
|
||||
"templates/torrent.html": templates_torrent_html,
|
||||
"templates/feed.xml": templates_feed_xml,
|
||||
"templates/homepage.html": templates_homepage_html,
|
||||
"templates/statistics.html": templates_statistics_html,
|
||||
"templates/torrents.html": templates_torrents_html,
|
||||
"static/scripts/plotly-v1.26.1.min.js": static_scripts_plotly_v1_26_1_min_js,
|
||||
"static/scripts/statistics.js": static_scripts_statistics_js,
|
||||
"static/scripts/torrent.js": static_scripts_torrent_js,
|
||||
"static/styles/reset.css": static_styles_reset_css,
|
||||
"static/styles/statistics.css": static_styles_statistics_css,
|
||||
"static/styles/torrent.css": static_styles_torrent_css,
|
||||
"static/styles/torrents.css": static_styles_torrents_css,
|
||||
"static/styles/homepage.css": static_styles_homepage_css,
|
||||
"static/styles/essential.css": static_styles_essential_css,
|
||||
"static/assets/magnet.gif": static_assets_magnet_gif,
|
||||
"static/assets/feed.png": static_assets_feed_png,
|
||||
"static/fonts/NotoMono/LICENSE_OFL.txt": static_fonts_notomono_license_ofl_txt,
|
||||
"static/fonts/NotoMono/Regular.ttf": static_fonts_notomono_regular_ttf,
|
||||
"static/fonts/NotoSansUI/LICENSE_OFL.txt": static_fonts_notosansui_license_ofl_txt,
|
||||
"static/fonts/NotoSansUI/Bold.ttf": static_fonts_notosansui_bold_ttf,
|
||||
"static/fonts/NotoSansUI/BoldItalic.ttf": static_fonts_notosansui_bolditalic_ttf,
|
||||
"static/fonts/NotoSansUI/Italic.ttf": static_fonts_notosansui_italic_ttf,
|
||||
"static/fonts/NotoSansUI/Regular.ttf": static_fonts_notosansui_regular_ttf,
|
||||
|
||||
}
|
Before Width: | Height: | Size: 531 B After Width: | Height: | Size: 531 B |
Before Width: | Height: | Size: 148 B After Width: | Height: | Size: 148 B |
@ -39,7 +39,7 @@ html {
|
||||
}
|
||||
|
||||
pre {
|
||||
font-family: 'Noto Mono';
|
||||
font-family: 'Noto Mono', monospace;
|
||||
line-height: 1.2em;
|
||||
}
|
||||
|
||||
@ -49,6 +49,12 @@ body {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
@media (max-width: 616px) {
|
||||
body {
|
||||
padding: 1em 8px 1em 8px;
|
||||
}
|
||||
}
|
||||
|
||||
b {
|
||||
font-weight: bold;
|
||||
}
|
38
src/magneticow/data/static/styles/homepage.css
Normal file
38
src/magneticow/data/static/styles/homepage.css
Normal file
@ -0,0 +1,38 @@
|
||||
main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
|
||||
height: calc(100vh - 2*16px - 0.833em - 23px); /* 100vh - body's padding(s) - footer margin - footer height */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 616px) {
|
||||
main {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
main div#magneticow {
|
||||
white-space: nowrap;
|
||||
margin: 0 0.5em 0.5em 0;
|
||||
}
|
||||
|
||||
main form {
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
main form input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
main > div {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 0.833em;
|
||||
}
|
@ -1,12 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>{{ title }}</title>
|
||||
<title>{{ .Title }}</title>
|
||||
|
||||
{% for item in items %}
|
||||
<item>
|
||||
<title>{{ item.title }}</title>
|
||||
<pubDate>{{ item.DiscoveredOn }}</pubDate>
|
||||
<guid>{{ item.info_hash }}</guid>
|
||||
<enclosure url="magnet:?xt=urn:btih:{{ item.info_hash }}&dn={{ item.title }}" type="application/x-bittorrent" />
|
||||
<description><![CDATA[Seeders: {{ item.NSeeders }} - Leechers: {{ item.NLeechers }}]]></description>
|
||||
</item>
|
||||
{% endfor %}
|
||||
</channel>
|
24
src/magneticow/data/templates/homepage.html
Normal file
24
src/magneticow/data/templates/homepage.html
Normal file
@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>magneticow</title>
|
||||
<link rel="stylesheet" href="static/styles/reset.css">
|
||||
<link rel="stylesheet" href="static/styles/essential.css">
|
||||
<link rel="stylesheet" href="static/styles/homepage.css">
|
||||
<!-- <script src="script.js"></script> -->
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div id="magneticow"><b>magnetico<sup>w</sup></b>​<sub>(pre-alpha)</sub></div>
|
||||
<form action="/torrents" method="get" autocomplete="off" role="search">
|
||||
<input type="search" name="search" placeholder="Search the BitTorrent DHT" autofocus>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
~{{ . }} torrents available (see the <a href="/statistics">statistics</a>).
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
66
src/magneticow/data/templates/torrents.html
Normal file
66
src/magneticow/data/templates/torrents.html
Normal file
@ -0,0 +1,66 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{% if .search %}"{{.search}}"{% else %}Most recent torrents{% endif %} - magneticow</title>
|
||||
<link rel="stylesheet" href="static/styles/reset.css">
|
||||
<link rel="stylesheet" href="static/styles/essential.css">
|
||||
<link rel="stylesheet" href="static/styles/torrents.css">
|
||||
<!-- <script src="script.js"></script> -->
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div><a href="/"><b>magnetico<sup>w</sup></b></a>​<sub>(pre-alpha)</sub></div>
|
||||
<form action="/torrents" method="get" autocomplete="off" role="search">
|
||||
<input type="search" name="search" placeholder="Search the BitTorrent DHT" value="{{ .search }}">
|
||||
</form>
|
||||
<div>
|
||||
<a href="{{ .subscription_url }}"><img src="static/assets/feed.png"
|
||||
alt="feed icon" title="subscribe" /> subscribe</a>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><!-- Magnet link --></th>
|
||||
<th>Name</th>
|
||||
<th>Size</th>
|
||||
<th>Discovered on</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for torrent in torrents %}
|
||||
<tr>
|
||||
<td><a href="magnet:?xt=urn:btih:{{ .torrent.info_hash }}&dn={{ .torrent.name }}">
|
||||
<img src="static/assets/magnet.gif" alt="Magnet link"
|
||||
title="Download this torrent using magnet" /></a></td>
|
||||
<td><a href="/torrents/{{ .torrent.info_hash }}/{{ .torrent.name }}">{{ torrent.name }}</a></td>
|
||||
<td>{{ torrent.size }}</td>
|
||||
<td>{{ torrent.discovered_on }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
<footer>
|
||||
|
||||
<form action="/torrents" method="get">
|
||||
<button {% if page == 0 %}disabled{% endif %}>Previous</button>
|
||||
<input type="text" name="search" value="{{ search }}" hidden>
|
||||
{% if sorted_by %}
|
||||
<input type="text" name="sort_by" value="{{ sorted_by }}" hidden>
|
||||
{% endif %}
|
||||
<input type="number" name="page" value="{{ page - 1 }}" hidden>
|
||||
</form>
|
||||
<form action="/torrents" method="get">
|
||||
<button {% if not next_page_exists %}disabled{% endif %}>Next</button>
|
||||
<input type="text" name="search" value="{{ search }}" hidden>
|
||||
{% if sorted_by %}
|
||||
<input type="text" name="sort_by" value="{{ sorted_by }}" hidden>
|
||||
{% endif %}
|
||||
<input type="number" name="page" value="{{ page + 1 }}" hidden>
|
||||
</form>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
@ -1,25 +1,20 @@
|
||||
// magneticow - Lightweight web interface for magnetico.
|
||||
// Copyright (C) 2017 Mert Bora ALPER <bora@boramalper.org>
|
||||
// Dedicated to Cemile Binay, in whose hands I thrived.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify it under the terms of the
|
||||
// GNU General Public License as published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
|
||||
// even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
// General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with this program. If
|
||||
// not, see <http://www.gnu.org/licenses/>.
|
||||
package main
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"persistence"
|
||||
)
|
||||
|
||||
const N_TORRENTS = 20
|
||||
|
||||
var templates map[string]*template.Template
|
||||
var database persistence.Database
|
||||
|
||||
func main() {
|
||||
router := mux.NewRouter()
|
||||
@ -28,23 +23,48 @@ func main() {
|
||||
router.HandleFunc("/torrents/{infohash}", torrentsInfohashHandler)
|
||||
router.HandleFunc("/torrents/{infohash}/{name}", torrentsInfohashNameHandler)
|
||||
router.HandleFunc("/statistics", statisticsHandler)
|
||||
router.PathPrefix("/static").HandlerFunc(staticHandler)
|
||||
|
||||
router.HandleFunc("/feed", feedHandler)
|
||||
|
||||
templates = make(map[string]*template.Template)
|
||||
templates["feed"] = template.Must(template.New("feed").Parse(string(mustAsset("templates/feed.xml"))))
|
||||
templates["homepage"] = template.Must(template.New("homepage").Parse(string(mustAsset("templates/homepage.html"))))
|
||||
templates["statistics"] = template.Must(template.New("statistics").Parse(string(mustAsset("templates/statistics.html"))))
|
||||
templates["torrent"] = template.Must(template.New("torrent").Parse(string(mustAsset("templates/torrent.html"))))
|
||||
templates["torrents"] = template.Must(template.New("torrents").Parse(string(mustAsset("templates/torrents.html"))))
|
||||
|
||||
var err error
|
||||
database, err = persistence.MakeDatabase("sqlite3:///home/bora/.local/share/magneticod/database.sqlite3")
|
||||
if err != nil {
|
||||
panic(err.Error())
|
||||
}
|
||||
|
||||
http.ListenAndServe(":8080", router)
|
||||
}
|
||||
|
||||
|
||||
func rootHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
count, err := database.GetNumberOfTorrents()
|
||||
if err != nil {
|
||||
panic(err.Error())
|
||||
}
|
||||
templates["homepage"].Execute(w, count)
|
||||
}
|
||||
|
||||
|
||||
func torrentsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
/*
|
||||
newestTorrents, err := database.NewestTorrents(N_TORRENTS)
|
||||
if err != nil {
|
||||
panic(err.Error())
|
||||
}
|
||||
templates["torrents"].Execute(w, nil)
|
||||
*/
|
||||
}
|
||||
|
||||
|
||||
func torrentsInfohashHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// redirect to torrents/{infohash}/name
|
||||
}
|
||||
|
||||
|
||||
@ -61,3 +81,28 @@ func statisticsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
func feedHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
}
|
||||
|
||||
func staticHandler(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := Asset(r.URL.Path[1:])
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
var contentType string
|
||||
if strings.HasSuffix(r.URL.Path, ".css") {
|
||||
contentType = "text/css; charset=utf-8"
|
||||
} else { // fallback option
|
||||
contentType = http.DetectContentType(data)
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
func mustAsset(name string) []byte {
|
||||
data, err := Asset(name)
|
||||
if err != nil {
|
||||
log.Panicf("Could NOT access the requested resource `%s`: %s", name, err.Error())
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
26
src/persistence/Gopkg.toml
Normal file
26
src/persistence/Gopkg.toml
Normal file
@ -0,0 +1,26 @@
|
||||
|
||||
# Gopkg.toml example
|
||||
#
|
||||
# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md
|
||||
# for detailed Gopkg.toml documentation.
|
||||
#
|
||||
# required = ["github.com/user/thing/cmd/thing"]
|
||||
# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"]
|
||||
#
|
||||
# [[constraint]]
|
||||
# name = "github.com/user/project"
|
||||
# version = "1.0.0"
|
||||
#
|
||||
# [[constraint]]
|
||||
# name = "github.com/user/project2"
|
||||
# branch = "dev"
|
||||
# source = "github.com/myfork/project2"
|
||||
#
|
||||
# [[override]]
|
||||
# name = "github.com/x/y"
|
||||
# version = "2.4.0"
|
||||
|
||||
|
||||
[[constraint]]
|
||||
name = "go.uber.org/zap"
|
||||
version = "1.6.0"
|
116
src/persistence/interface.go
Normal file
116
src/persistence/interface.go
Normal file
@ -0,0 +1,116 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type Database interface {
|
||||
Engine() databaseEngine
|
||||
DoesTorrentExist(infoHash []byte) (bool, error)
|
||||
// GiveAnIncompleteTorrentByInfoHash returns (*gives*) an incomplete -i.e. one that doesn't have
|
||||
// readme downloaded yet- torrent from the database.
|
||||
// GiveAnIncompleteTorrent might return a nil slice for infoHash, a nil string, and a nil err,
|
||||
// meaning that no incomplete torrent could be found in the database (congrats!).
|
||||
GiveAnIncompleteTorrent(pathRegex *regexp.Regexp, maxSize uint) (infoHash []byte, path string, err error)
|
||||
GiveAStaleTorrent() (infoHash []byte, err error)
|
||||
AddNewTorrent(infoHash []byte, name string, files []File) error
|
||||
AddReadme(infoHash []byte, path string, content string) error
|
||||
Close() error
|
||||
|
||||
// GetNumberOfTorrents returns the number of torrents saved in the database. Might be an
|
||||
// approximation.
|
||||
GetNumberOfTorrents() (uint, error)
|
||||
NewestTorrents(n uint) ([]TorrentMetadata, error)
|
||||
SearchTorrents(query string, orderBy orderingCriteria, descending bool, mustHaveReadme bool) ([]TorrentMetadata, error)
|
||||
// GetTorrents returns the TorrentExtMetadata for the torrent of the given infoHash. Might return
|
||||
// nil, nil if the torrent does not exist in the database.
|
||||
GetTorrent(infoHash []byte) (*TorrentMetadata, error)
|
||||
GetFiles(infoHash []byte) ([]File, error)
|
||||
GetReadme(infoHash []byte) (string, error)
|
||||
GetStatistics(from ISO8601, period uint) (*Statistics, error)
|
||||
}
|
||||
|
||||
type orderingCriteria uint8
|
||||
|
||||
const (
|
||||
BY_NAME orderingCriteria = 1
|
||||
BY_SIZE = 2
|
||||
BY_DISCOVERED_ON = 3
|
||||
BY_N_FILES = 4
|
||||
BY_N_SEEDERS = 5
|
||||
BY_N_LEECHERS = 6
|
||||
BY_UPDATED_ON = 7
|
||||
BY_N_SEEDERS_TO_N_LEECHERS_RATIO = 8
|
||||
BY_N_SEEDERS_PLUS_N_LEECHERS = 9
|
||||
)
|
||||
|
||||
|
||||
type statisticsGranularity uint8
|
||||
type ISO8601 string
|
||||
|
||||
const (
|
||||
MINUTELY_STATISTICS statisticsGranularity = 1
|
||||
HOURLY_STATISTICS = 2
|
||||
DAILY_STATISTICS = 3
|
||||
WEEKLY_STATISTICS = 4
|
||||
MONTHLY_STATISTICS = 5
|
||||
YEARLY_STATISTICS = 6
|
||||
)
|
||||
|
||||
type databaseEngine uint8
|
||||
|
||||
const (
|
||||
SQLITE3_ENGINE databaseEngine = 1
|
||||
)
|
||||
|
||||
type Statistics struct {
|
||||
Granularity statisticsGranularity
|
||||
From ISO8601
|
||||
Period uint
|
||||
|
||||
// All these slices below have the exact length equal to the Period.
|
||||
NTorrentsDiscovered []uint
|
||||
NFilesDiscovered []uint
|
||||
NReadmesDownloaded []uint
|
||||
NTorrentsUpdated []uint
|
||||
}
|
||||
|
||||
type File struct {
|
||||
Size int64
|
||||
Path string
|
||||
}
|
||||
|
||||
type TorrentMetadata struct {
|
||||
infoHash []byte
|
||||
name string
|
||||
size uint64
|
||||
discoveredOn int64
|
||||
hasReadme bool
|
||||
nFiles uint
|
||||
// values below 0 indicates that no data is available:
|
||||
nSeeders int
|
||||
nLeechers int
|
||||
updatedOn int
|
||||
}
|
||||
|
||||
func MakeDatabase(rawURL string) (Database, error) {
|
||||
url_, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch url_.Scheme {
|
||||
case "sqlite3":
|
||||
return makeSqlite3Database(url_)
|
||||
|
||||
case "postgresql":
|
||||
return nil, fmt.Errorf("postgresql is not yet supported!")
|
||||
|
||||
case "mysql":
|
||||
return nil, fmt.Errorf("mysql is not yet supported!")
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unknown URI scheme (database engine)!")
|
||||
}
|
1
src/persistence/mysql.go
Normal file
1
src/persistence/mysql.go
Normal file
@ -0,0 +1 @@
|
||||
package persistence
|
1
src/persistence/postgresql.go
Normal file
1
src/persistence/postgresql.go
Normal file
@ -0,0 +1 @@
|
||||
package persistence
|
427
src/persistence/sqlite3.go
Normal file
427
src/persistence/sqlite3.go
Normal file
@ -0,0 +1,427 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"path"
|
||||
"os"
|
||||
"fmt"
|
||||
"database/sql"
|
||||
"regexp"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"time"
|
||||
)
|
||||
|
||||
type sqlite3Database struct {
|
||||
conn *sql.DB
|
||||
}
|
||||
|
||||
func (db *sqlite3Database) Engine() databaseEngine {
|
||||
return SQLITE3_ENGINE
|
||||
}
|
||||
|
||||
func makeSqlite3Database(url_ *url.URL) (Database, error) {
|
||||
db := new(sqlite3Database)
|
||||
|
||||
dbDir, _ := path.Split(url_.Path)
|
||||
if err := os.MkdirAll(dbDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("for directory `%s`: %s", dbDir, err.Error())
|
||||
}
|
||||
|
||||
var err error
|
||||
db.conn, err = sql.Open("sqlite3", url_.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// > Open may just validate its arguments without creating a connection to the database. To
|
||||
// > verify that the data source name is valid, call Ping.
|
||||
// https://golang.org/pkg/database/sql/#Open
|
||||
if err = db.conn.Ping(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := db.setupDatabase(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func (db *sqlite3Database) DoesTorrentExist(infoHash []byte) (bool, error) {
|
||||
rows, err := db.conn.Query("SELECT 1 FROM torrents WHERE info_hash = ?;", infoHash)
|
||||
if err != nil {
|
||||
return false, err;
|
||||
}
|
||||
|
||||
// If rows.Next() returns true, meaning that the torrent is in the database, return true; else
|
||||
// return false.
|
||||
exists := rows.Next()
|
||||
|
||||
if err = rows.Close(); err != nil {
|
||||
return false, err;
|
||||
}
|
||||
|
||||
return exists, nil;
|
||||
}
|
||||
|
||||
func (db *sqlite3Database) GiveAnIncompleteTorrent(pathRegex *regexp.Regexp, maxSize uint) (infoHash []byte, path string, err error) {
|
||||
rows, err := db.conn.Query("SELECT info_hash FROM torrents WHERE has_readme = 0;")
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
if rows.Next() != true {
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
if err = rows.Scan(&infoHash); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
if err = rows.Close(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// TODO
|
||||
return infoHash, "", nil
|
||||
}
|
||||
|
||||
func (db *sqlite3Database) GiveAStaleTorrent() (infoHash []byte, err error) {
|
||||
// TODO
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (db *sqlite3Database) AddNewTorrent(infoHash []byte, name string, files []File) error {
|
||||
// Although we check whether the torrent exists in the database before asking MetadataSink to
|
||||
// fetch its metadata, the torrent can also exists in the Sink before that. Now, if a torrent in
|
||||
// the sink is still being fetched, that's still not a problem as we just add the new peer for
|
||||
// the torrent and exit, but if the torrent is complete (i.e. its metadata) and if its waiting
|
||||
// in the channel to be received, a race condition arises when we query the database and seeing
|
||||
// that it doesn't exists there, add it to the sink.
|
||||
// Hence check for the last time whether the torrent exists in the database, and only if not,
|
||||
// add it.
|
||||
exists, err := db.DoesTorrentExist(infoHash)
|
||||
if err != nil {
|
||||
return err;
|
||||
} else if exists {
|
||||
return nil;
|
||||
}
|
||||
|
||||
tx, err := db.conn.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// If everything goes as planned and no error occurs, we will commit the transaction before
|
||||
// returning from the function so the tx.Rollback() call will fail, trying to rollback a
|
||||
// committed transaction. BUT, if an error occurs, we'll get our transaction rollback'ed, which
|
||||
// is nice.
|
||||
defer tx.Rollback()
|
||||
|
||||
var total_size int64 = 0
|
||||
for _, file := range files {
|
||||
total_size += file.Size
|
||||
}
|
||||
|
||||
res, err := tx.Exec(`
|
||||
INSERT INTO torrents (
|
||||
info_hash,
|
||||
name,
|
||||
total_size,
|
||||
discovered_on,
|
||||
n_files,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?);
|
||||
`, infoHash, name, total_size, time.Now().Unix(), len(files))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var lastInsertId int64
|
||||
if lastInsertId, err = res.LastInsertId(); err != nil {
|
||||
return fmt.Errorf("sql.Result.LastInsertId()! %s", err.Error())
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
_, err = tx.Exec("INSERT INTO files (torrent_id, size, path) VALUES (?, ?, ?);",
|
||||
lastInsertId, file.Size, file.Path,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *sqlite3Database) AddReadme(infoHash []byte, path string, content string) error {
|
||||
_, err := db.conn.Exec(
|
||||
`UPDATE files SET is_readme = 1, content = ?
|
||||
WHERE path = ? AND (SELECT id FROM torrents WHERE info_hash = ?) = torrent_id;`,
|
||||
content, path, infoHash,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *sqlite3Database) Close() error {
|
||||
return db.conn.Close()
|
||||
}
|
||||
|
||||
func (db *sqlite3Database) GetNumberOfTorrents() (uint, error) {
|
||||
// COUNT(ROWID) is much more inefficient since it scans the whole table, so use MAX(ROWID)
|
||||
rows, err := db.conn.Query("SELECT MAX(ROWID) FROM torrents;")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if rows.Next() != true {
|
||||
fmt.Errorf("No rows returned from `SELECT MAX(ROWID)`!")
|
||||
}
|
||||
|
||||
var n uint
|
||||
if err = rows.Scan(&n); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if err = rows.Close(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (db *sqlite3Database) NewestTorrents(n uint) ([]TorrentMetadata, error) {
|
||||
rows, err := db.conn.Query(`
|
||||
SELECT
|
||||
info_hash,
|
||||
name,
|
||||
total_size,
|
||||
discovered_on,
|
||||
has_readme,
|
||||
n_files,
|
||||
n_seeders,
|
||||
n_leechers,
|
||||
updated_on
|
||||
FROM torrents
|
||||
ORDER BY discovered_on DESC LIMIT ?;
|
||||
`, n,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var torrents []TorrentMetadata
|
||||
for rows.Next() {
|
||||
tm := new(TorrentMetadata)
|
||||
rows.Scan(
|
||||
&tm.infoHash, &tm.name, &tm.discoveredOn, &tm.hasReadme, &tm.nFiles, &tm.nSeeders,
|
||||
&tm.nLeechers, &tm.updatedOn,
|
||||
)
|
||||
torrents = append(torrents, *tm)
|
||||
}
|
||||
|
||||
if err = rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return torrents, nil
|
||||
}
|
||||
|
||||
func (db *sqlite3Database) SearchTorrents(query string, orderBy orderingCriteria, descending bool, mustHaveReadme bool) ([]TorrentMetadata, error) { // TODO
|
||||
// TODO:
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (db *sqlite3Database) GetTorrent(infoHash []byte) (*TorrentMetadata, error) {
|
||||
rows, err := db.conn.Query(
|
||||
`SELECT
|
||||
info_hash,
|
||||
name,
|
||||
size,
|
||||
discovered_on,
|
||||
has_readme,
|
||||
n_files,
|
||||
n_seeders,
|
||||
n_leechers,
|
||||
updated_on
|
||||
FROM torrents
|
||||
WHERE info_hash = ?`,
|
||||
infoHash,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if rows.Next() != true {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
tm := new(TorrentMetadata)
|
||||
rows.Scan(
|
||||
&tm.infoHash, &tm.name, &tm.discoveredOn, &tm.hasReadme, &tm.nFiles, &tm.nSeeders,
|
||||
&tm.nLeechers, &tm.updatedOn,
|
||||
)
|
||||
|
||||
if err = rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tm, nil
|
||||
}
|
||||
|
||||
func (db *sqlite3Database) GetFiles(infoHash []byte) ([]File, error) {
|
||||
// TODO
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (db *sqlite3Database) GetReadme(infoHash []byte) (string, error) {
|
||||
// TODO
|
||||
return "", nil
|
||||
}
|
||||
|
||||
|
||||
func (db *sqlite3Database) GetStatistics(from ISO8601, period uint) (*Statistics, error) {
|
||||
// TODO
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (db *sqlite3Database) commitQueuedTorrents() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *sqlite3Database) setupDatabase() error {
|
||||
// Enable Write-Ahead Logging for SQLite as "WAL provides more concurrency as readers do not
|
||||
// block writers and a writer does not block readers. Reading and writing can proceed
|
||||
// concurrently."
|
||||
// Caveats:
|
||||
// * Might be unsupported by OSes other than Windows and UNIXes.
|
||||
// * Does not work over a network filesystem.
|
||||
// * Transactions that involve changes against multiple ATTACHed databases are not atomic
|
||||
// across all databases as a set.
|
||||
// See: https://www.sqlite.org/wal.html
|
||||
//
|
||||
// Force SQLite to use disk, instead of memory, for all temporary files to reduce the memory
|
||||
// footprint.
|
||||
//
|
||||
// Enable foreign key constraints in SQLite which are crucial to prevent programmer errors on
|
||||
// our side.
|
||||
_, err := db.conn.Exec(`
|
||||
PRAGMA journal_mode=WAL;
|
||||
PRAGMA temp_store=1;
|
||||
PRAGMA foreign_keys=ON;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := db.conn.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// If everything goes as planned and no error occurs, we will commit the transaction before
|
||||
// returning from the function so the tx.Rollback() call will fail, trying to rollback a
|
||||
// committed transaction. BUT, if an error occurs, we'll get our transaction rollback'ed, which
|
||||
// is nice.
|
||||
defer tx.Rollback()
|
||||
|
||||
// Essential, and valid for all user_version`s:
|
||||
// TODO: "torrent_id" column of the "files" table can be NULL, how can we fix this in a new schema?
|
||||
_, err = tx.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS torrents (
|
||||
id INTEGER PRIMARY KEY,
|
||||
info_hash BLOB NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
total_size INTEGER NOT NULL CHECK(total_size > 0),
|
||||
discovered_on INTEGER NOT NULL CHECK(discovered_on > 0)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id INTEGER PRIMARY KEY,
|
||||
torrent_id INTEGER REFERENCES torrents ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
size INTEGER NOT NULL,
|
||||
path TEXT NOT NULL
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the user_version:
|
||||
res, err := tx.Query("PRAGMA user_version;")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var userVersion int;
|
||||
if res.Next() != true {
|
||||
return fmt.Errorf("PRAGMA user_version did not return any rows!")
|
||||
}
|
||||
if err = res.Scan(&userVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch userVersion {
|
||||
// Upgrade from user_version 0 to 1
|
||||
// The Change:
|
||||
// * `info_hash_index` is recreated as UNIQUE.
|
||||
case 0:
|
||||
zap.S().Warnf("Updating database schema from 0 to 1... (this might take a while)")
|
||||
_, err = tx.Exec(`
|
||||
DROP INDEX info_hash_index;
|
||||
CREATE UNIQUE INDEX info_hash_index ON torrents (info_hash);
|
||||
PRAGMA user_version = 1;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fallthrough
|
||||
// Upgrade from user_version 1 to 2
|
||||
// The Change:
|
||||
// * Added `is_readme` and `content` columns to the `files` table, and the constraints & the
|
||||
// the indices they entail.
|
||||
// * Added unique index `readme_index` on `files` table.
|
||||
case 1:
|
||||
zap.S().Warnf("Updating database schema from 1 to 2... (this might take a while)")
|
||||
// We introduce two new columns here: content BLOB, and is_readme INTEGER which we treat as
|
||||
// a bool (hence the CHECK).
|
||||
// The reason for the change is that as we introduce the new "readme" feature which
|
||||
// downloads a readme file as a torrent descriptor, we needed to store it somewhere in the
|
||||
// database with the following conditions:
|
||||
//
|
||||
// 1. There can be one and only one readme (content) for a given torrent; hence the
|
||||
// UNIQUE INDEX on (torrent_id, is_description) (remember that SQLite treats each NULL
|
||||
// value as distinct [UNIQUE], see https://sqlite.org/nulls.html).
|
||||
// 2. We would like to keep the readme (content) associated with the file it came from;
|
||||
// hence we modify the files table instead of the torrents table.
|
||||
//
|
||||
// Regarding the implementation details, following constraints arise:
|
||||
//
|
||||
// 1. The column is_readme is either NULL or 1, and if it is 1, then content column cannot
|
||||
// be NULL (but might be an empty BLOB). Vice versa, if content column of a row is,
|
||||
// NULL then is_readme must be NULL.
|
||||
//
|
||||
// This is to prevent unused content fields filling up the database, and to catch
|
||||
// programmers' errors.
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE files ADD COLUMN is_readme INTEGER CHECK (is_readme IS NULL OR is_readme=1) DEFAULT NULL;
|
||||
ALTER TABLE files ADD COLUMN content BLOB CHECK((content IS NULL AND is_readme IS NULL) OR (content IS NOT NULL AND is_readme=1)) DEFAULT NULL;
|
||||
CREATE UNIQUE INDEX readme_index ON files (torrent_id, is_readme);
|
||||
PRAGMA user_version = 2;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
Loading…
Reference in New Issue
Block a user