diff --git a/src/MonoTorrent/BEncoding/BEncodedDictionary.cs b/src/MonoTorrent/BEncoding/BEncodedDictionary.cs new file mode 100644 index 000000000..7efd11392 --- /dev/null +++ b/src/MonoTorrent/BEncoding/BEncodedDictionary.cs @@ -0,0 +1,321 @@ +using System; +using System.IO; +using System.Collections.Generic; +using System.Text; + +namespace MonoTorrent.BEncoding +{ + /// + /// Class representing a BEncoded Dictionary + /// + public class BEncodedDictionary : BEncodedValue, IDictionary + { + #region Member Variables + + private SortedDictionary dictionary; + + #endregion + + + #region Constructors + + /// + /// Create a new BEncodedDictionary + /// + public BEncodedDictionary() + { + this.dictionary = new SortedDictionary(); + } + + #endregion + + + #region Encode/Decode Methods + + /// + /// Encodes the dictionary to a byte[] + /// + /// The buffer to encode the data to + /// The offset to start writing the data to + /// + public override int Encode(byte[] buffer, int offset) + { + int written = 0; + + //Dictionaries start with 'd' + buffer[offset] = (byte)'d'; + written++; + + foreach (KeyValuePair keypair in this) + { + written += keypair.Key.Encode(buffer, offset + written); + written += keypair.Value.Encode(buffer, offset + written); + } + + // Dictionaries end with 'e' + buffer[offset + written] = (byte)'e'; + written++; + return written; + } + + + /// + /// + /// + /// + internal override void DecodeInternal(RawReader reader) + { + DecodeInternal(reader, reader.StrictDecoding); + } + + private void DecodeInternal(RawReader reader, bool strictDecoding) + { + BEncodedString key = null; + BEncodedValue value = null; + BEncodedString oldkey = null; + + if (reader.ReadByte() != 'd') + throw new BEncodingException("Invalid data found. Aborting"); // Remove the leading 'd' + + while ((reader.PeekByte() != -1) && (reader.PeekByte() != 'e')) + { + key = (BEncodedString)BEncodedValue.Decode(reader); // keys have to be BEncoded strings + + if (oldkey != null && oldkey.CompareTo(key) > 0) + if (strictDecoding) + throw new BEncodingException(String.Format( + "Illegal BEncodedDictionary. The attributes are not ordered correctly. Old key: {0}, New key: {1}", + oldkey, key)); + + oldkey = key; + value = BEncodedValue.Decode(reader); // the value is a BEncoded value + dictionary.Add(key, value); + } + + if (reader.ReadByte() != 'e') // remove the trailing 'e' + throw new BEncodingException("Invalid data found. Aborting"); + } + + public static BEncodedDictionary DecodeTorrent(byte[] bytes) + { + return DecodeTorrent(new MemoryStream(bytes)); + } + + public static BEncodedDictionary DecodeTorrent(Stream s) + { + return DecodeTorrent(new RawReader(s)); + } + + + /// + /// Special decoding method for torrent files - allows dictionary attributes to be out of order for the + /// overall torrent file, but imposes strict rules on the info dictionary. + /// + /// + public static BEncodedDictionary DecodeTorrent(RawReader reader) + { + BEncodedString key = null; + BEncodedValue value = null; + BEncodedDictionary torrent = new BEncodedDictionary(); + if (reader.ReadByte() != 'd') + throw new BEncodingException("Invalid data found. Aborting"); // Remove the leading 'd' + + while ((reader.PeekByte() != -1) && (reader.PeekByte() != 'e')) + { + key = (BEncodedString)BEncodedValue.Decode(reader); // keys have to be BEncoded strings + + if (reader.PeekByte() == 'd') + { + value = new BEncodedDictionary(); + if (key.Text.ToLower().Equals("info")) + ((BEncodedDictionary)value).DecodeInternal(reader, true); + else + ((BEncodedDictionary)value).DecodeInternal(reader, false); + } + else + value = BEncodedValue.Decode(reader); // the value is a BEncoded value + + torrent.dictionary.Add(key, value); + } + + if (reader.ReadByte() != 'e') // remove the trailing 'e' + throw new BEncodingException("Invalid data found. Aborting"); + + return torrent; + } + + #endregion + + + #region Helper Methods + + /// + /// Returns the size of the dictionary in bytes using UTF8 encoding + /// + /// + public override int LengthInBytes() + { + int length = 0; + length += 1; // Dictionaries start with 'd' + + foreach (KeyValuePair keypair in this.dictionary) + { + length += keypair.Key.LengthInBytes(); + length += keypair.Value.LengthInBytes(); + } + length += 1; // Dictionaries end with 'e' + return length; + } + + #endregion + + + #region Overridden Methods + public override bool Equals(object obj) + { + BEncodedValue val; + BEncodedDictionary other = obj as BEncodedDictionary; + if (other == null) + return false; + + if (this.dictionary.Count != other.dictionary.Count) + return false; + + foreach (KeyValuePair keypair in this.dictionary) + { + if (!other.TryGetValue(keypair.Key, out val)) + return false; + + if (!keypair.Value.Equals(val)) + return false; + } + + return true; + } + + public override int GetHashCode() + { + int result = 0; + foreach (KeyValuePair keypair in dictionary) + { + result ^= keypair.Key.GetHashCode(); + result ^= keypair.Value.GetHashCode(); + } + + return result; + } + + public override string ToString() + { + return System.Text.Encoding.UTF8.GetString(Encode()); + } + #endregion + + + #region IDictionary and IList methods + public void Add(BEncodedString key, BEncodedValue value) + { + this.dictionary.Add(key, value); + } + + public void Add(KeyValuePair item) + { + this.dictionary.Add(item.Key, item.Value); + } + public void Clear() + { + this.dictionary.Clear(); + } + + public bool Contains(KeyValuePair item) + { + if (!this.dictionary.ContainsKey(item.Key)) + return false; + + return this.dictionary[item.Key].Equals(item.Value); + } + + public bool ContainsKey(BEncodedString key) + { + return this.dictionary.ContainsKey(key); + } + + public void CopyTo(KeyValuePair[] array, int arrayIndex) + { + this.dictionary.CopyTo(array, arrayIndex); + } + + public int Count + { + get { return this.dictionary.Count; } + } + + //public int IndexOf(KeyValuePair item) + //{ + // return this.dictionary.IndexOf(item); + //} + + //public void Insert(int index, KeyValuePair item) + //{ + // this.dictionary.Insert(index, item); + //} + + public bool IsReadOnly + { + get { return false; } + } + + public bool Remove(BEncodedString key) + { + return this.dictionary.Remove(key); + } + + public bool Remove(KeyValuePair item) + { + return this.dictionary.Remove(item.Key); + } + + //public void RemoveAt(int index) + //{ + // this.dictionary.RemoveAt(index); + //} + + public bool TryGetValue(BEncodedString key, out BEncodedValue value) + { + return this.dictionary.TryGetValue(key, out value); + } + + public BEncodedValue this[BEncodedString key] + { + get { return this.dictionary[key]; } + set { this.dictionary[key] = value; } + } + + //public KeyValuePair this[int index] + //{ + // get { return this.dictionary[index]; } + // set { this.dictionary[index] = value; } + //} + + public ICollection Keys + { + get { return this.dictionary.Keys; } + } + + public ICollection Values + { + get { return this.dictionary.Values; } + } + + public IEnumerator> GetEnumerator() + { + return this.dictionary.GetEnumerator(); + } + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() + { + return this.dictionary.GetEnumerator(); + } + #endregion + } +} \ No newline at end of file diff --git a/src/MonoTorrent/BEncoding/BEncodedList.cs b/src/MonoTorrent/BEncoding/BEncodedList.cs new file mode 100644 index 000000000..5dda07fe8 --- /dev/null +++ b/src/MonoTorrent/BEncoding/BEncodedList.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.IO; + +namespace MonoTorrent.BEncoding +{ + /// + /// Class representing a BEncoded list + /// + public class BEncodedList : BEncodedValue, IList + { + #region Member Variables + + private List list; + + #endregion + + + #region Constructors + /// + /// Create a new BEncoded List with default capacity + /// + public BEncodedList() + : this(new List()) + { + } + + /// + /// Create a new BEncoded List with the supplied capacity + /// + /// The initial capacity + public BEncodedList(int capacity) + : this(new List(capacity)) + { + + } + + public BEncodedList(IEnumerable list) + { + if (list == null) + throw new ArgumentNullException("list"); + + this.list = new List(list); + } + + private BEncodedList(List value) + { + this.list = value; + } + + #endregion + + + #region Encode/Decode Methods + + + /// + /// Encodes the list to a byte[] + /// + /// The buffer to encode the list to + /// The offset to start writing the data at + /// + public override int Encode(byte[] buffer, int offset) + { + int written = 0; + buffer[offset] = (byte)'l'; + written++; + for (int i = 0; i < this.list.Count; i++) + written += this.list[i].Encode(buffer, offset + written); + buffer[offset + written] = (byte)'e'; + written++; + return written; + } + + /// + /// Decodes a BEncodedList from the given StreamReader + /// + /// + internal override void DecodeInternal(RawReader reader) + { + if (reader.ReadByte() != 'l') // Remove the leading 'l' + throw new BEncodingException("Invalid data found. Aborting"); + + while ((reader.PeekByte() != -1) && (reader.PeekByte() != 'e')) + list.Add(BEncodedValue.Decode(reader)); + + if (reader.ReadByte() != 'e') // Remove the trailing 'e' + throw new BEncodingException("Invalid data found. Aborting"); + } + #endregion + + + #region Helper Methods + /// + /// Returns the size of the list in bytes + /// + /// + public override int LengthInBytes() + { + int length = 0; + + length += 1; // Lists start with 'l' + for (int i=0; i < this.list.Count; i++) + length += this.list[i].LengthInBytes(); + + length += 1; // Lists end with 'e' + return length; + } + #endregion + + + #region Overridden Methods + public override bool Equals(object obj) + { + BEncodedList other = obj as BEncodedList; + + if (other == null) + return false; + + for (int i = 0; i < this.list.Count; i++) + if (!this.list[i].Equals(other.list[i])) + return false; + + return true; + } + + + public override int GetHashCode() + { + int result = 0; + for (int i = 0; i < list.Count; i++) + result ^= list[i].GetHashCode(); + + return result; + } + + + public override string ToString() + { + return System.Text.Encoding.UTF8.GetString(Encode()); + } + #endregion + + + #region IList methods + public void Add(BEncodedValue item) + { + this.list.Add(item); + } + + public void AddRange (IEnumerable collection) + { + list.AddRange (collection); + } + + public void Clear() + { + this.list.Clear(); + } + + public bool Contains(BEncodedValue item) + { + return this.list.Contains(item); + } + + public void CopyTo(BEncodedValue[] array, int arrayIndex) + { + this.list.CopyTo(array, arrayIndex); + } + + public int Count + { + get { return this.list.Count; } + } + + public int IndexOf(BEncodedValue item) + { + return this.list.IndexOf(item); + } + + public void Insert(int index, BEncodedValue item) + { + this.list.Insert(index, item); + } + + public bool IsReadOnly + { + get { return false; } + } + + public bool Remove(BEncodedValue item) + { + return this.list.Remove(item); + } + + public void RemoveAt(int index) + { + this.list.RemoveAt(index); + } + + public BEncodedValue this[int index] + { + get { return this.list[index]; } + set { this.list[index] = value; } + } + + public IEnumerator GetEnumerator() + { + return this.list.GetEnumerator(); + } + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() + { + return this.GetEnumerator(); + } + #endregion + } +} \ No newline at end of file diff --git a/src/MonoTorrent/BEncoding/BEncodedNumber.cs b/src/MonoTorrent/BEncoding/BEncodedNumber.cs new file mode 100644 index 000000000..9f227399c --- /dev/null +++ b/src/MonoTorrent/BEncoding/BEncodedNumber.cs @@ -0,0 +1,209 @@ +using System; +using System.IO; +using System.Text; +using System.Collections.Generic; + +namespace MonoTorrent.BEncoding +{ + /// + /// Class representing a BEncoded number + /// + public class BEncodedNumber : BEncodedValue, IComparable + { + #region Member Variables + /// + /// The value of the BEncodedNumber + /// + public long Number + { + get { return number; } + set { number = value; } + } + internal long number; + #endregion + + + #region Constructors + public BEncodedNumber() + : this(0) + { + } + + /// + /// Create a new BEncoded number with the given value + /// + /// The inital value of the BEncodedNumber + public BEncodedNumber(long value) + { + this.number = value; + } + + public static implicit operator BEncodedNumber(long value) + { + return new BEncodedNumber(value); + } + #endregion + + + #region Encode/Decode Methods + + /// + /// Encodes this number to the supplied byte[] starting at the supplied offset + /// + /// The buffer to write the data to + /// The offset to start writing the data at + /// + public override int Encode(byte[] buffer, int offset) + { + long number = this.number; + + int written = offset; + buffer[written++] = (byte)'i'; + + if (number < 0) + { + buffer[written++] = (byte)'-'; + number = -number; + } + // Reverse the number '12345' to get '54321' + long reversed = 0; + for (long i = number; i != 0; i /= 10) + reversed = reversed * 10 + i % 10; + + // Write each digit of the reversed number to the array. We write '1' + // first, then '2', etc + for (long i = reversed; i != 0; i /= 10) + buffer[written++] = (byte)(i % 10 + '0'); + + if (number == 0) + buffer[written++] = (byte)'0'; + + // If the original number ends in one or more zeros, they are lost + // when we reverse the number. We add them back in here. + for (long i = number; i % 10 == 0 && number != 0; i /= 10) + buffer[written++] = (byte)'0'; + + buffer[written++] = (byte)'e'; + return written - offset; + } + + + /// + /// Decodes a BEncoded number from the supplied RawReader + /// + /// RawReader containing a BEncoded Number + internal override void DecodeInternal(RawReader reader) + { + int sign = 1; + if (reader == null) + throw new ArgumentNullException("reader"); + + if (reader.ReadByte() != 'i') // remove the leading 'i' + throw new BEncodingException("Invalid data found. Aborting."); + + if (reader.PeekByte() == '-') + { + sign = -1; + reader.ReadByte (); + } + + int letter; + while (((letter = reader.PeekByte()) != -1) && letter != 'e') + { + if(letter < '0' || letter > '9') + throw new BEncodingException("Invalid number found."); + number = number * 10 + (letter - '0'); + reader.ReadByte (); + } + if (reader.ReadByte() != 'e') //remove the trailing 'e' + throw new BEncodingException("Invalid data found. Aborting."); + + number *= sign; + } + #endregion + + + #region Helper Methods + /// + /// Returns the length of the encoded string in bytes + /// + /// + public override int LengthInBytes() + { + long number = this.number; + int count = 2; // account for the 'i' and 'e' + + if (number == 0) + return count + 1; + + if (number < 0) + { + number = -number; + count++; + } + for (long i = number; i != 0; i /= 10) + count++; + + return count; + } + + + public int CompareTo(object other) + { + if (other is BEncodedNumber || other is long || other is int) + return CompareTo((BEncodedNumber)other); + + return -1; + } + + public int CompareTo(BEncodedNumber other) + { + if (other == null) + throw new ArgumentNullException("other"); + + return this.number.CompareTo(other.number); + } + + + public int CompareTo(long other) + { + return this.number.CompareTo(other); + } + #endregion + + + #region Overridden Methods + /// + /// + /// + /// + /// + public override bool Equals(object obj) + { + BEncodedNumber obj2 = obj as BEncodedNumber; + if (obj2 == null) + return false; + + return (this.number == obj2.number); + } + + /// + /// + /// + /// + public override int GetHashCode() + { + return this.number.GetHashCode(); + } + + /// + /// + /// + /// + public override string ToString() + { + return (this.number.ToString()); + } + #endregion + } +} diff --git a/src/MonoTorrent/BEncoding/BEncodedString.cs b/src/MonoTorrent/BEncoding/BEncodedString.cs new file mode 100644 index 000000000..e4e65fb10 --- /dev/null +++ b/src/MonoTorrent/BEncoding/BEncodedString.cs @@ -0,0 +1,220 @@ +using System; +using System.IO; +using System.Collections; +using System.Text; +using MonoTorrent.Common; +using MonoTorrent.Messages; + +namespace MonoTorrent.BEncoding +{ + /// + /// Class representing a BEncoded string + /// + public class BEncodedString : BEncodedValue, IComparable + { + #region Member Variables + + /// + /// The value of the BEncodedString + /// + public string Text + { + get { return Encoding.UTF8.GetString(textBytes); } + set { textBytes = Encoding.UTF8.GetBytes(value); } + } + + /// + /// The underlying byte[] associated with this BEncodedString + /// + public byte[] TextBytes + { + get { return this.textBytes; } + } + private byte[] textBytes; + #endregion + + + #region Constructors + /// + /// Create a new BEncodedString using UTF8 encoding + /// + public BEncodedString() + : this(new byte[0]) + { + } + + /// + /// Create a new BEncodedString using UTF8 encoding + /// + /// + public BEncodedString(char[] value) + : this(System.Text.Encoding.UTF8.GetBytes(value)) + { + } + + /// + /// Create a new BEncodedString using UTF8 encoding + /// + /// Initial value for the string + public BEncodedString(string value) + : this(System.Text.Encoding.UTF8.GetBytes(value)) + { + } + + + /// + /// Create a new BEncodedString using UTF8 encoding + /// + /// + public BEncodedString(byte[] value) + { + this.textBytes = value; + } + + + public static implicit operator BEncodedString(string value) + { + return new BEncodedString(value); + } + public static implicit operator BEncodedString(char[] value) + { + return new BEncodedString(value); + } + public static implicit operator BEncodedString(byte[] value) + { + return new BEncodedString(value); + } + #endregion + + + #region Encode/Decode Methods + + + /// + /// Encodes the BEncodedString to a byte[] using the supplied Encoding + /// + /// The buffer to encode the string to + /// The offset at which to save the data to + /// The encoding to use + /// The number of bytes encoded + public override int Encode(byte[] buffer, int offset) + { + int written = offset; + written += Message.WriteAscii(buffer, written, textBytes.Length.ToString ()); + written += Message.WriteAscii(buffer, written, ":"); + written += Message.Write(buffer, written, textBytes); + return written - offset; + } + + + /// + /// Decodes a BEncodedString from the supplied StreamReader + /// + /// The StreamReader containing the BEncodedString + internal override void DecodeInternal(RawReader reader) + { + if (reader == null) + throw new ArgumentNullException("reader"); + + int letterCount; + string length = string.Empty; + + while ((reader.PeekByte() != -1) && (reader.PeekByte() != ':')) // read in how many characters + length += (char)reader.ReadByte(); // the string is + + if (reader.ReadByte() != ':') // remove the ':' + throw new BEncodingException("Invalid data found. Aborting"); + + if (!int.TryParse(length, out letterCount)) + throw new BEncodingException(string.Format("Invalid BEncodedString. Length was '{0}' instead of a number", length)); + + this.textBytes = new byte[letterCount]; + if (reader.Read(textBytes, 0, letterCount) != letterCount) + throw new BEncodingException("Couldn't decode string"); + } + #endregion + + + #region Helper Methods + public string Hex + { + get { return BitConverter.ToString(TextBytes); } + } + + public override int LengthInBytes() + { + // The length is equal to the length-prefix + ':' + length of data + int prefix = 1; // Account for ':' + + // Count the number of characters needed for the length prefix + for (int i = textBytes.Length; i != 0; i = i/10) + prefix += 1; + + if (textBytes.Length == 0) + prefix++; + + return prefix + textBytes.Length; + } + + public int CompareTo(object other) + { + return CompareTo(other as BEncodedString); + } + + + public int CompareTo(BEncodedString other) + { + if (other == null) + return 1; + + int difference=0; + int length = this.textBytes.Length > other.textBytes.Length ? other.textBytes.Length : this.textBytes.Length; + + for (int i = 0; i < length; i++) + if ((difference = this.textBytes[i].CompareTo(other.textBytes[i])) != 0) + return difference; + + if (this.textBytes.Length == other.textBytes.Length) + return 0; + + return this.textBytes.Length > other.textBytes.Length ? 1 : -1; + } + + #endregion + + + #region Overridden Methods + + public override bool Equals(object obj) + { + if (obj == null) + return false; + + BEncodedString other; + if (obj is string) + other = new BEncodedString((string)obj); + else if (obj is BEncodedString) + other = (BEncodedString)obj; + else + return false; + + return Toolbox.ByteMatch(this.textBytes, other.textBytes); + } + + public override int GetHashCode() + { + int hash = 0; + for (int i = 0; i < this.textBytes.Length; i++) + hash += this.textBytes[i]; + + return hash; + } + + public override string ToString() + { + return System.Text.Encoding.UTF8.GetString(textBytes); + } + + #endregion + } +} diff --git a/src/MonoTorrent/BEncoding/BEncodingException.cs b/src/MonoTorrent/BEncoding/BEncodingException.cs new file mode 100644 index 000000000..6e95b9ecf --- /dev/null +++ b/src/MonoTorrent/BEncoding/BEncodingException.cs @@ -0,0 +1,30 @@ +using System; +using System.Text; +using System.Runtime.Serialization; + +namespace MonoTorrent.BEncoding +{ + [Serializable] + public class BEncodingException : Exception + { + public BEncodingException() + : base() + { + } + + public BEncodingException(string message) + : base(message) + { + } + + public BEncodingException(string message, Exception innerException) + : base(message, innerException) + { + } + + protected BEncodingException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + } + } +} diff --git a/src/MonoTorrent/BEncoding/IBEncodedValue.cs b/src/MonoTorrent/BEncoding/IBEncodedValue.cs new file mode 100644 index 000000000..0097e0e50 --- /dev/null +++ b/src/MonoTorrent/BEncoding/IBEncodedValue.cs @@ -0,0 +1,203 @@ +using System; +using System.IO; +using System.Text; + +namespace MonoTorrent.BEncoding +{ + /// + /// Base interface for all BEncoded values. + /// + public abstract class BEncodedValue + { + internal abstract void DecodeInternal(RawReader reader); + + /// + /// Encodes the BEncodedValue into a byte array + /// + /// Byte array containing the BEncoded Data + public byte[] Encode() + { + byte[] buffer = new byte[LengthInBytes()]; + if (Encode(buffer, 0) != buffer.Length) + throw new BEncodingException("Error encoding the data"); + + return buffer; + } + + + /// + /// Encodes the BEncodedValue into the supplied buffer + /// + /// The buffer to encode the information to + /// The offset in the buffer to start writing the data + /// + public abstract int Encode(byte[] buffer, int offset); + + public static T Clone (T value) + where T : BEncodedValue + { + Check.Value (value); + return (T) BEncodedValue.Decode (value.Encode ()); + } + + /// + /// Interface for all BEncoded values + /// + /// The byte array containing the BEncoded data + /// + public static BEncodedValue Decode(byte[] data) + { + if (data == null) + throw new ArgumentNullException("data"); + + using (RawReader stream = new RawReader(new MemoryStream(data))) + return (Decode(stream)); + } + + internal static BEncodedValue Decode(byte[] buffer, bool strictDecoding) + { + return Decode(buffer, 0, buffer.Length, strictDecoding); + } + + /// + /// Decode BEncoded data in the given byte array + /// + /// The byte array containing the BEncoded data + /// The offset at which the data starts at + /// The number of bytes to be decoded + /// BEncodedValue containing the data that was in the byte[] + public static BEncodedValue Decode(byte[] buffer, int offset, int length) + { + return Decode(buffer, offset, length, true); + } + + public static BEncodedValue Decode(byte[] buffer, int offset, int length, bool strictDecoding) + { + if (buffer == null) + throw new ArgumentNullException("buffer"); + + if (offset < 0 || length < 0) + throw new IndexOutOfRangeException("Neither offset or length can be less than zero"); + + if (offset > buffer.Length - length) + throw new ArgumentOutOfRangeException("length"); + + using (RawReader reader = new RawReader(new MemoryStream(buffer, offset, length), strictDecoding)) + return (BEncodedValue.Decode(reader)); + } + + + /// + /// Decode BEncoded data in the given stream + /// + /// The stream containing the BEncoded data + /// BEncodedValue containing the data that was in the stream + public static BEncodedValue Decode(Stream stream) + { + if (stream == null) + throw new ArgumentNullException("stream"); + + return Decode(new RawReader(stream)); + } + + + /// + /// Decode BEncoded data in the given RawReader + /// + /// The RawReader containing the BEncoded data + /// BEncodedValue containing the data that was in the stream + public static BEncodedValue Decode(RawReader reader) + { + if (reader == null) + throw new ArgumentNullException("reader"); + + BEncodedValue data; + switch (reader.PeekByte()) + { + case ('i'): // Integer + data = new BEncodedNumber(); + break; + + case ('d'): // Dictionary + data = new BEncodedDictionary(); + break; + + case ('l'): // List + data = new BEncodedList(); + break; + + case ('1'): // String + case ('2'): + case ('3'): + case ('4'): + case ('5'): + case ('6'): + case ('7'): + case ('8'): + case ('9'): + case ('0'): + data = new BEncodedString(); + break; + + default: + throw new BEncodingException("Could not find what value to decode"); + } + + data.DecodeInternal(reader); + return data; + } + + + /// + /// Interface for all BEncoded values + /// + /// The byte array containing the BEncoded data + /// + public static T Decode(byte[] data) where T : BEncodedValue + { + return (T)BEncodedValue.Decode(data); + } + + + /// + /// Decode BEncoded data in the given byte array + /// + /// The byte array containing the BEncoded data + /// The offset at which the data starts at + /// The number of bytes to be decoded + /// BEncodedValue containing the data that was in the byte[] + public static T Decode(byte[] buffer, int offset, int length) where T : BEncodedValue + { + return BEncodedValue.Decode(buffer, offset, length, true); + } + + public static T Decode(byte[] buffer, int offset, int length, bool strictDecoding) where T : BEncodedValue + { + return (T)BEncodedValue.Decode(buffer, offset, length, strictDecoding); + } + + + /// + /// Decode BEncoded data in the given stream + /// + /// The stream containing the BEncoded data + /// BEncodedValue containing the data that was in the stream + public static T Decode(Stream stream) where T : BEncodedValue + { + return (T)BEncodedValue.Decode(stream); + } + + + public static T Decode(RawReader reader) where T : BEncodedValue + { + return (T)BEncodedValue.Decode(reader); + } + + + /// + /// Returns the size of the byte[] needed to encode this BEncodedValue + /// + /// + public abstract int LengthInBytes(); + } +} diff --git a/src/MonoTorrent/BEncoding/RawReader.cs b/src/MonoTorrent/BEncoding/RawReader.cs new file mode 100644 index 000000000..47cc983f6 --- /dev/null +++ b/src/MonoTorrent/BEncoding/RawReader.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.IO; + +namespace MonoTorrent.BEncoding +{ + public class RawReader : Stream + { + bool hasPeek; + Stream input; + byte[] peeked; + bool strictDecoding; + + public bool StrictDecoding + { + get { return strictDecoding; } + } + + public RawReader(Stream input) + : this(input, true) + { + + } + + public RawReader(Stream input, bool strictDecoding) + { + this.input = input; + this.peeked = new byte[1]; + this.strictDecoding = strictDecoding; + } + + public override bool CanRead + { + get { return input.CanRead; } + } + + public override bool CanSeek + { + get { return input.CanSeek; } + } + + public override bool CanWrite + { + get { return false; } + } + + public override void Flush() + { + throw new NotSupportedException(); + } + + public override long Length + { + get { return input.Length; } + } + + public int PeekByte() + { + if (!hasPeek) + hasPeek = Read(peeked, 0, 1) == 1; + return hasPeek ? peeked[0] : -1; + } + + public override int ReadByte() + { + if (hasPeek) + { + hasPeek = false; + return peeked[0]; + } + return base.ReadByte(); + } + + public override long Position + { + get + { + if (hasPeek) + return input.Position - 1; + return input.Position; + } + set + { + if (value != Position) + { + hasPeek = false; + input.Position = value; + } + } + } + + public override int Read(byte[] buffer, int offset, int count) + { + int read = 0; + if (hasPeek && count > 0) + { + hasPeek = false; + buffer[offset] = peeked[0]; + offset++; + count--; + read++; + } + read += input.Read(buffer, offset, count); + return read; + } + + public override long Seek(long offset, SeekOrigin origin) + { + long val; + if (hasPeek && origin == SeekOrigin.Current) + val = input.Seek(offset - 1, origin); + else + val = input.Seek(offset, origin); + hasPeek = false; + return val; + } + + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + } +} diff --git a/src/MonoTorrent/BitField.cs b/src/MonoTorrent/BitField.cs new file mode 100644 index 000000000..dc23bc626 --- /dev/null +++ b/src/MonoTorrent/BitField.cs @@ -0,0 +1,420 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Text; + +namespace MonoTorrent +{ + /// + /// This class is for represting the Peer's bitfield + /// + public class BitField : ICloneable, IEnumerable + { + #region Member Variables + + private int[] array; + private int length; + private int trueCount; + + internal bool AllFalse + { + get { return this.trueCount == 0; } + } + + internal bool AllTrue + { + get { return this.trueCount == this.length; } + } + + public int Length + { + get { return this.length; } + } + + public double PercentComplete + { + get { return (double)this.trueCount / this.length * 100.0; } + } + + #endregion + + + #region Constructors + public BitField(byte[] array, int length) + : this(length) + { + this.FromArray(array, 0, array.Length); + } + + public BitField(int length) + { + if (length < 0) + throw new ArgumentOutOfRangeException("length"); + + this.length = length; + this.array = new int[(length + 31) / 32]; + } + + public BitField(bool[] array) + { + this.length = array.Length; + this.array = new int[(array.Length + 31) / 32]; + for (int i = 0; i < array.Length; i++) + this.Set(i, array[i]); + } + + #endregion + + + #region Methods BitArray + + public bool this[int index] + { + get { return this.Get(index); } + internal set { this.Set(index, value); } + } + + object ICloneable.Clone() + { + return this.Clone(); + } + + public BitField Clone() + { + BitField b = new BitField(this.length); + Buffer.BlockCopy(this.array, 0, b.array, 0, this.array.Length * 4); + b.trueCount = this.trueCount; + return b; + } + + public BitField From(BitField value) + { + this.Check(value); + Buffer.BlockCopy(value.array, 0, this.array, 0, this.array.Length * 4); + this.trueCount = value.trueCount; + return this; + } + + public BitField Not() + { + for (int i = 0; i < this.array.Length; i++) + this.array[i] = ~this.array[i]; + + this.trueCount = this.length - this.trueCount; + return this; + } + + public BitField And(BitField value) + { + this.Check(value); + + for (int i = 0; i < this.array.Length; i++) + this.array[i] &= value.array[i]; + + this.Validate(); + return this; + } + + internal BitField NAnd(BitField value) + { + this.Check(value); + + for (int i = 0; i < this.array.Length; i++) + this.array[i] &= ~value.array[i]; + + this.Validate(); + return this; + } + + public BitField Or(BitField value) + { + this.Check(value); + + for (int i = 0; i < this.array.Length; i++) + this.array[i] |= value.array[i]; + + this.Validate(); + return this; + } + + public BitField Xor(BitField value) + { + this.Check(value); + + for (int i = 0; i < this.array.Length; i++) + this.array[i] ^= value.array[i]; + + this.Validate(); + return this; + } + + public override bool Equals(object obj) + { + BitField bf = obj as BitField; + + if (bf == null || this.array.Length != bf.array.Length || this.TrueCount != bf.TrueCount) + return false; + + for (int i = 0; i < this.array.Length; i++) + if (this.array[i] != bf.array[i]) + return false; + + return true; + } + + public int FirstTrue() + { + return this.FirstTrue(0, this.length); + } + + public int FirstTrue(int startIndex, int endIndex) + { + int start; + int end; + + // If the number of pieces is an exact multiple of 32, we need to decrement by 1 so we don't overrun the array + // For the case when endIndex == 0, we need to ensure we don't go negative + int loopEnd = Math.Min((endIndex / 32), this.array.Length - 1); + for (int i = (startIndex / 32); i <= loopEnd; i++) + { + if (this.array[i] == 0) // This one has no true values + continue; + + start = i * 32; + end = start + 32; + start = (start < startIndex) ? startIndex : start; + end = (end > this.length) ? this.length : end; + end = (end > endIndex) ? endIndex : end; + if (end == this.Length && end > 0) + end--; + + for (int j = start; j <= end; j++) + if (this.Get(j)) // This piece is true + return j; + } + + return -1; // Nothing is true + } + + public int FirstFalse() + { + return this.FirstFalse(0, this.Length); + } + + public int FirstFalse(int startIndex, int endIndex) + { + int start; + int end; + + // If the number of pieces is an exact multiple of 32, we need to decrement by 1 so we don't overrun the array + // For the case when endIndex == 0, we need to ensure we don't go negative + int loopEnd = Math.Min((endIndex / 32), this.array.Length - 1); + for (int i = (startIndex / 32); i <= loopEnd; i++) + { + if (this.array[i] == ~0) // This one has no false values + continue; + + start = i * 32; + end = start + 32; + start = (start < startIndex) ? startIndex : start; + end = (end > this.length) ? this.length : end; + end = (end > endIndex) ? endIndex : end; + if (end == this.Length && end > 0) + end--; + + for (int j = start; j <= end; j++) + if (!this.Get(j)) // This piece is true + return j; + } + + return -1; // Nothing is true + } + internal void FromArray(byte[] buffer, int offset, int length) + { + int end = this.Length / 32; + for (int i = 0; i < end; i++) + this.array[i] = (buffer[offset++] << 24) | + (buffer[offset++] << 16) | + (buffer[offset++] << 8) | + (buffer[offset++] << 0); + + int shift = 24; + for (int i = end * 32; i < this.Length; i += 8) + { + this.array[this.array.Length - 1] |= buffer[offset++] << shift; + shift -= 8; + } + this.Validate(); + } + + bool Get(int index) + { + if (index < 0 || index >= this.length) + throw new ArgumentOutOfRangeException("index"); + + return (this.array[index >> 5] & (1 << (31 - (index & 31)))) != 0; + } + + public IEnumerator GetEnumerator() + { + for (int i = 0; i < this.length; i++) + yield return this.Get(i); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return this.GetEnumerator(); + } + + public override int GetHashCode() + { + int count = 0; + for (int i = 0; i < this.array.Length; i++) + count += this.array[i]; + + return count; + } + + public int LengthInBytes + { + get { return (this.length + 7) / 8; } //8 bits in a byte. + } + + public BitField Set(int index, bool value) + { + if (index < 0 || index >= this.length) + throw new ArgumentOutOfRangeException("index"); + + if (value) + { + if ((this.array[index >> 5] & (1 << (31 - (index & 31)))) == 0)// If it's not already true + this.trueCount++; // Increase true count + this.array[index >> 5] |= (1 << (31 - index & 31)); + } + else + { + if ((this.array[index >> 5] & (1 << (31 - (index & 31)))) != 0)// If it's not already false + this.trueCount--; // Decrease true count + this.array[index >> 5] &= ~(1 << (31 - (index & 31))); + } + + return this; + } + + internal BitField SetTrue(params int[] indices) + { + foreach (int index in indices) + this.Set(index, true); + return this; + } + + internal BitField SetFalse(params int[] indices) + { + foreach (int index in indices) + this.Set(index, false); + return this; + } + + internal BitField SetAll(bool value) + { + if (value) + { + for (int i = 0; i < this.array.Length; i++) + this.array[i] = ~0; + this.Validate(); + } + + else + { + for (int i = 0; i < this.array.Length; i++) + this.array[i] = 0; + this.trueCount = 0; + } + + return this; + } + + internal byte[] ToByteArray() + { + byte[] data = new byte[this.LengthInBytes]; + this.ToByteArray(data, 0); + return data; + } + + internal void ToByteArray(byte[] buffer, int offset) + { + if (buffer == null) + throw new ArgumentNullException("buffer"); + + this.ZeroUnusedBits(); + int end = this.Length / 32; + for (int i = 0; i < end; i++) + { + buffer[offset++] = (byte)(this.array[i] >> 24); + buffer[offset++] = (byte)(this.array[i] >> 16); + buffer[offset++] = (byte)(this.array[i] >> 8); + buffer[offset++] = (byte)(this.array[i] >> 0); + } + + int shift = 24; + for (int i = end * 32; i < this.Length; i += 8) + { + buffer[offset++] = (byte)(this.array[this.array.Length - 1] >> shift); + shift -= 8; + } + } + + public override string ToString() + { + StringBuilder sb = new StringBuilder(this.array.Length * 16); + for (int i = 0; i < this.Length; i++) + { + sb.Append(this.Get(i) ? 'T' : 'F'); + sb.Append(' '); + } + + return sb.ToString(0, sb.Length - 1); + } + + public int TrueCount + { + get { return this.trueCount; } + } + + void Validate() + { + this.ZeroUnusedBits(); + + // Update the population count + uint count = 0; + for (int i = 0; i < this.array.Length; i++) + { + uint v = (uint)this.array[i]; + v = v - ((v >> 1) & 0x55555555); + v = (v & 0x33333333) + ((v >> 2) & 0x33333333); + count += (((v + (v >> 4) & 0xF0F0F0F) * 0x1010101)) >> 24; + } + this.trueCount = (int)count ; + } + + void ZeroUnusedBits() + { + if (this.array.Length == 0) + return; + + // Zero the unused bits + int shift = 32 - this.length % 32; + if (shift != 0) + this.array[this.array.Length - 1] &= (-1 << shift); + } + + void Check(BitField value) + { + MonoTorrent.Check.Value(value); + if (this.length != value.length) + throw new ArgumentException("BitFields are of different lengths", "value"); + } + + #endregion + } +} diff --git a/src/MonoTorrent/Check.cs b/src/MonoTorrent/Check.cs new file mode 100644 index 000000000..1d2cde648 --- /dev/null +++ b/src/MonoTorrent/Check.cs @@ -0,0 +1,235 @@ +using System; + +namespace MonoTorrent +{ + public static class Check + { + static void DoCheck(object toCheck, string name) + { + if (toCheck == null) + throw new ArgumentNullException(name); + } + + static void IsNullOrEmpty(string toCheck, string name) + { + DoCheck(toCheck, name); + if (toCheck.Length == 0) + throw new ArgumentException("Cannot be empty", name); + } + + public static void Address(object address) + { + DoCheck(address, "address"); + } + + public static void AddressRange(object addressRange) + { + DoCheck(addressRange, "addressRange"); + } + + public static void AddressRanges(object addressRanges) + { + DoCheck(addressRanges, "addressRanges"); + } + + public static void Announces(object announces) + { + DoCheck(announces, "announces"); + } + + public static void BaseDirectory(object baseDirectory) + { + DoCheck(baseDirectory, "baseDirectory"); + } + + internal static void BaseType(Type baseType) + { + DoCheck(baseType, "baseType"); + } + + internal static void Buffer(object buffer) + { + DoCheck(buffer, "buffer"); + } + + internal static void Cache(object cache) + { + DoCheck(cache, "cache"); + } + + public static void Data(object data) + { + DoCheck(data, "data"); + } + + public static void Destination (object destination) + { + DoCheck (destination, "destination"); + } + + public static void Endpoint(object endpoint) + { + DoCheck(endpoint, "endpoint"); + } + + public static void File(object file) + { + DoCheck(file, "file"); + } + + public static void Files(object files) + { + DoCheck(files, "files"); + } + + public static void FileSource(object fileSource) + { + DoCheck(fileSource, "fileSource"); + } + + public static void InfoHash(object infoHash) + { + DoCheck(infoHash, "infoHash"); + } + + public static void Key (object key) + { + DoCheck (key, "key"); + } + + public static void Limiter(object limiter) + { + DoCheck(limiter, "limiter"); + } + + public static void Listener(object listener) + { + DoCheck(listener, "listener"); + } + + public static void Location(object location) + { + DoCheck(location, "location"); + } + + public static void MagnetLink(object magnetLink) + { + DoCheck(magnetLink, "magnetLink"); + } + + public static void Manager(object manager) + { + DoCheck(manager, "manager"); + } + + public static void Mappings (object mappings) + { + DoCheck (mappings, "mappings"); + } + + public static void Metadata(object metadata) + { + DoCheck(metadata, "metadata"); + } + + public static void Name (object name) + { + DoCheck (name, "name"); + } + + public static void Path(object path) + { + DoCheck(path, "path"); + } + + public static void Paths (object paths) + { + DoCheck (paths, "paths"); + } + + public static void PathNotEmpty(string path) + { + IsNullOrEmpty(path, "path"); + } + + public static void Peer (object peer) + { + DoCheck (peer, "peer"); + } + + public static void Peers (object peers) + { + DoCheck (peers, "peers"); + } + + public static void Picker(object picker) + { + DoCheck(picker, "picker"); + } + + public static void Result(object result) + { + DoCheck(result, "result"); + } + + public static void SavePath(object savePath) + { + DoCheck(savePath, "savePath"); + } + + public static void Settings(object settings) + { + DoCheck(settings, "settings"); + } + + internal static void SpecificType(Type specificType) + { + DoCheck(specificType, "specificType"); + } + + public static void Stream(object stream) + { + DoCheck(stream, "stream"); + } + + public static void Torrent(object torrent) + { + DoCheck(torrent, "torrent"); + } + + public static void TorrentInformation(object torrentInformation) + { + DoCheck(torrentInformation, "torrentInformation"); + } + + public static void TorrentSave(object torrentSave) + { + DoCheck(torrentSave, "torrentSave"); + } + + public static void Tracker(object tracker) + { + DoCheck(tracker, "tracker"); + } + + public static void Url(object url) + { + DoCheck(url, "url"); + } + + public static void Uri(Uri uri) + { + DoCheck(uri, "uri"); + } + + public static void Value(object value) + { + DoCheck(value, "value"); + } + + public static void Writer(object writer) + { + DoCheck(writer, "writer"); + } + } +} \ No newline at end of file diff --git a/src/MonoTorrent/Enums.cs b/src/MonoTorrent/Enums.cs new file mode 100644 index 000000000..56779fc27 --- /dev/null +++ b/src/MonoTorrent/Enums.cs @@ -0,0 +1,13 @@ +namespace MonoTorrent +{ + public enum Priority + { + DoNotDownload = 0, + Lowest = 1, + Low = 2, + Normal = 4, + High = 8, + Highest = 16, + Immediate = 32 + } +} diff --git a/src/MonoTorrent/Exceptions/MessageException.cs b/src/MonoTorrent/Exceptions/MessageException.cs new file mode 100644 index 000000000..87d3c26ab --- /dev/null +++ b/src/MonoTorrent/Exceptions/MessageException.cs @@ -0,0 +1,30 @@ +using System; + +namespace MonoTorrent.Exceptions +{ + public class MessageException : TorrentException + { + public MessageException() + : base() + { + } + + + public MessageException(string message) + : base(message) + { + } + + + public MessageException(string message, Exception innerException) + : base(message, innerException) + { + } + + + public MessageException(System.Runtime.Serialization.SerializationInfo info, System.Runtime.Serialization.StreamingContext context) + : base(info, context) + { + } + } +} diff --git a/src/MonoTorrent/HashAlgoFactory.cs b/src/MonoTorrent/HashAlgoFactory.cs new file mode 100644 index 000000000..13b119729 --- /dev/null +++ b/src/MonoTorrent/HashAlgoFactory.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Security.Cryptography; + +namespace MonoTorrent +{ + public static class HashAlgoFactory + { + static Dictionary algos = new Dictionary(); + + static HashAlgoFactory() + { + Register(); + Register(); + } + + public static void Register() + where T : HashAlgorithm + where U : HashAlgorithm + { + Register(typeof(T), typeof(U)); + } + + public static void Register(Type baseType, Type specificType) + { + Check.BaseType(baseType); + Check.SpecificType(specificType); + + lock (algos) + algos[baseType] = specificType; + } + + public static T Create() + where T : HashAlgorithm + { + if (algos.ContainsKey(typeof(T))) + return (T)Activator.CreateInstance(algos[typeof(T)]); + return null; + } + } +} diff --git a/src/MonoTorrent/Hashes.cs b/src/MonoTorrent/Hashes.cs new file mode 100644 index 000000000..f674f9e63 --- /dev/null +++ b/src/MonoTorrent/Hashes.cs @@ -0,0 +1,93 @@ +using System; + +namespace MonoTorrent +{ + public class Hashes + { + #region Constants + /// + /// Hash code length (in bytes) + /// + internal static readonly int HashCodeLength = 20; + #endregion + + + #region Private Fields + + private int count; + private byte[] hashData; + + #endregion Private Fields + + + #region Properties + + /// + /// Number of Hashes (equivalent to number of Pieces) + /// + public int Count + { + get { return this.count; } + } + + #endregion Properties + + + #region Constructors + + internal Hashes(byte[] hashData, int count) + { + this.hashData = hashData; + this.count = count; + } + + #endregion Constructors + + + #region Methods + + /// + /// Determine whether a calculated hash is equal to our stored hash + /// + /// Hash code to check + /// Index of hash/piece to verify against + /// true iff hash is equal to our stored hash, false otherwise + public bool IsValid(byte[] hash, int hashIndex) + { + if (hash == null) + throw new ArgumentNullException("hash"); + + if (hash.Length != HashCodeLength) + throw new ArgumentException(string.Format("Hash must be {0} bytes in length", HashCodeLength), "hash"); + + if (hashIndex < 0 || hashIndex > this.count) + throw new ArgumentOutOfRangeException("hashIndex", string.Format("hashIndex must be between 0 and {0}", this.count)); + + int start = hashIndex * HashCodeLength; + for (int i = 0; i < HashCodeLength; i++) + if (hash[i] != this.hashData[i + start]) + return false; + + return true; + } + + /// + /// Returns the hash for a specific piece + /// + /// Piece/hash index to return + /// byte[] (length HashCodeLength) containing hashdata + public byte[] ReadHash(int hashIndex) + { + if (hashIndex < 0 || hashIndex >= this.count) + throw new ArgumentOutOfRangeException("hashIndex"); + + // Read out our specified piece's hash data + byte[] hash = new byte[HashCodeLength]; + Buffer.BlockCopy(this.hashData, hashIndex * HashCodeLength, hash, 0, HashCodeLength); + + return hash; + } + + #endregion Methods + } +} diff --git a/src/MonoTorrent/InfoHash.cs b/src/MonoTorrent/InfoHash.cs new file mode 100644 index 000000000..dc9fc44a8 --- /dev/null +++ b/src/MonoTorrent/InfoHash.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.Text; +using MonoTorrent.Common; +using System.Web; + +namespace MonoTorrent +{ + public class InfoHash : IEquatable + { + static Dictionary base32DecodeTable; + + static InfoHash() + { + base32DecodeTable = new Dictionary(); + string table = "abcdefghijklmnopqrstuvwxyz234567"; + for (int i = 0; i < table.Length; i++) + base32DecodeTable[table[i]] = (byte)i; + } + + byte[] hash; + + internal byte[] Hash + { + get { return hash; } + } + + public InfoHash(byte[] infoHash) + { + Check.InfoHash(infoHash); + if (infoHash.Length != 20) + throw new ArgumentException("Infohash must be exactly 20 bytes long"); + hash = (byte[])infoHash.Clone(); + } + + public override bool Equals(object obj) + { + return Equals(obj as InfoHash); + } + + public bool Equals(byte[] other) + { + return other == null || other.Length != 20 ? false : Toolbox.ByteMatch(Hash, other); + } + + public bool Equals(InfoHash other) + { + return this == other; + } + + public override int GetHashCode() + { + // Equality is based generally on checking 20 positions, checking 4 should be enough + // for the hashcode as infohashes are randomly distributed. + return Hash[0] | (Hash[1] << 8) | (Hash[2] << 16) | (Hash[3] << 24); + } + + public byte[] ToArray() + { + return (byte[])hash.Clone(); + } + + public string ToHex() + { + StringBuilder sb = new StringBuilder(40); + for (int i = 0; i < hash.Length; i++) + { + string hex = hash[i].ToString("X"); + if (hex.Length != 2) + sb.Append("0"); + sb.Append(hex); + } + return sb.ToString(); + } + + public override string ToString() + { + return BitConverter.ToString(hash); + } + + public string UrlEncode() + { + return UriHelper.UrlEncode(Hash); + } + + public static bool operator ==(InfoHash left, InfoHash right) + { + if ((object)left == null) + return (object)right == null; + if ((object)right == null) + return false; + return Toolbox.ByteMatch(left.Hash, right.Hash); + } + + public static bool operator !=(InfoHash left, InfoHash right) + { + return !(left == right); + } + + public static InfoHash FromBase32(string infoHash) + { + Check.InfoHash (infoHash); + if (infoHash.Length != 32) + throw new ArgumentException("Infohash must be a base32 encoded 32 character string"); + + infoHash = infoHash.ToLower(); + int infohashOffset =0 ; + byte[] hash = new byte[20]; + var temp = new byte[8]; + for (int i = 0; i < hash.Length; ) { + for (int j=0; j < 8; j++) + if (!base32DecodeTable.TryGetValue(infoHash[infohashOffset++], out temp[j])) + throw new ArgumentException ("infoHash", "Value is not a valid base32 encoded string"); + + //8 * 5bits = 40 bits = 5 bytes + hash[i++] = (byte)((temp[0] << 3) | (temp [1]>> 2)); + hash[i++] = (byte)((temp[1] << 6) | (temp[2] << 1) | (temp[3] >> 4)); + hash[i++] = (byte)((temp[3] << 4) | (temp [4]>> 1)); + hash[i++] = (byte)((temp[4] << 7) | (temp[5] << 2) | (temp [6]>> 3)); + hash[i++] = (byte)((temp[6] << 5) | temp[7]); + } + + return new InfoHash(hash); + } + + public static InfoHash FromHex(string infoHash) + { + Check.InfoHash (infoHash); + if (infoHash.Length != 40) + throw new ArgumentException("Infohash must be 40 characters long"); + + byte[] hash = new byte[20]; + for (int i = 0; i < hash.Length; i++) + hash[i] = byte.Parse(infoHash.Substring(i * 2, 2), System.Globalization.NumberStyles.HexNumber); + + return new InfoHash(hash); + } + + public static InfoHash FromMagnetLink(string magnetLink) + { + Check.MagnetLink(magnetLink); + if (!magnetLink.StartsWith("magnet:?")) + throw new ArgumentException("Invalid magnet link format"); + magnetLink = magnetLink.Substring("magnet:?".Length); + int hashStart = magnetLink.IndexOf("xt=urn:btih:"); + if (hashStart == -1) + throw new ArgumentException("Magnet link does not contain an infohash"); + hashStart += "xt=urn:btih:".Length; + + int hashEnd = magnetLink.IndexOf('&', hashStart); + if (hashEnd == -1) + hashEnd = magnetLink.Length; + + switch (hashEnd - hashStart) + { + case 32: + return FromBase32(magnetLink.Substring(hashStart, 32)); + case 40: + return FromHex(magnetLink.Substring(hashStart, 40)); + default: + throw new ArgumentException("Infohash must be base32 or hex encoded."); + } + } + + public static InfoHash UrlDecode(string infoHash) + { + Check.InfoHash(infoHash); + return new InfoHash(UriHelper.UrlDecode(infoHash)); + } + } +} diff --git a/src/MonoTorrent/MagnetLink.cs b/src/MonoTorrent/MagnetLink.cs new file mode 100644 index 000000000..1ee56c4c8 --- /dev/null +++ b/src/MonoTorrent/MagnetLink.cs @@ -0,0 +1,95 @@ + +using System; +using System.Collections.Generic; +using System.Text; + +namespace MonoTorrent +{ + public class MagnetLink + { + public RawTrackerTier AnnounceUrls { + get; private set; + } + + public InfoHash InfoHash { + get; private set; + } + + public string Name { + get; private set; + } + + public List Webseeds { + get; private set; + } + + public MagnetLink (string url) + { + Check.Url (url); + AnnounceUrls = new RawTrackerTier (); + Webseeds = new List (); + + ParseMagnetLink (url); + } + + void ParseMagnetLink (string url) + { + string[] splitStr = url.Split ('?'); + if (splitStr.Length == 0 || splitStr[0] != "magnet:") + throw new FormatException ("The magnet link must start with 'magnet:?'."); + + if (splitStr.Length == 1) + return;//no parametter + + string[] parameters = splitStr[1].Split ('&', ';'); + + for (int i = 0; i < parameters.Length ; i++) + { + string[] keyval = parameters[i].Split ('='); + if (keyval.Length != 2) + throw new FormatException ("A field-value pair of the magnet link contain more than one equal'."); + switch (keyval[0].Substring(0, 2)) + { + case "xt"://exact topic + if (InfoHash != null) + throw new FormatException ("More than one infohash in magnet link is not allowed."); + + string val = keyval[1].Substring(9); + switch (keyval[1].Substring(0, 9)) + { + case "urn:sha1:"://base32 hash + case "urn:btih:": + if (val.Length == 32) + InfoHash = InfoHash.FromBase32 (val); + else if (val.Length == 40) + InfoHash = InfoHash.FromHex (val); + else + throw new FormatException("Infohash must be base32 or hex encoded."); + break; + } + break; + case "tr" ://address tracker + var bytes = UriHelper.UrlDecode(keyval[1]); + AnnounceUrls.Add(Encoding.UTF8.GetString(bytes)); + break; + case "as"://Acceptable Source + Webseeds.Add (keyval[1]); + break; + case "dn"://display name + var name = UriHelper.UrlDecode(keyval[1]); + Name = Encoding.UTF8.GetString(name); + break; + case "xl"://exact length + case "xs":// eXact Source - P2P link. + case "kt"://keyword topic + case "mt"://manifest topic + //not supported for moment + break; + default: + //not supported + break; + } + } + } + } +} diff --git a/src/MonoTorrent/Messages/IMessage.cs b/src/MonoTorrent/Messages/IMessage.cs new file mode 100644 index 000000000..62512b6c7 --- /dev/null +++ b/src/MonoTorrent/Messages/IMessage.cs @@ -0,0 +1,12 @@ +namespace MonoTorrent.Messages +{ + interface IMessage + { + int ByteLength { get;} + + byte[] Encode(); + int Encode(byte[] buffer, int offset); + + void Decode(byte[] buffer, int offset, int length); + } +} diff --git a/src/MonoTorrent/Messages/Message.cs b/src/MonoTorrent/Messages/Message.cs new file mode 100644 index 000000000..bd59d9952 --- /dev/null +++ b/src/MonoTorrent/Messages/Message.cs @@ -0,0 +1,164 @@ +using System; +using System.Net; +using MonoTorrent.Exceptions; + +namespace MonoTorrent.Messages +{ + public abstract class Message : IMessage + { + public abstract int ByteLength { get; } + + protected int CheckWritten(int written) + { + if (written != this.ByteLength) + throw new MessageException("Message encoded incorrectly. Incorrect number of bytes written"); + return written; + } + + public abstract void Decode(byte[] buffer, int offset, int length); + + public byte[] Encode() + { + byte[] buffer = new byte[this.ByteLength]; + this.Encode(buffer, 0); + return buffer; + } + + public abstract int Encode(byte[] buffer, int offset); + + static public byte ReadByte(byte[] buffer, int offset) + { + return buffer[offset]; + } + + static public byte ReadByte(byte[] buffer, ref int offset) + { + byte b = buffer[offset]; + offset++; + return b; + } + + static public byte[] ReadBytes(byte[] buffer, int offset, int count) + { + return ReadBytes(buffer, ref offset, count); + } + + static public byte[] ReadBytes(byte[] buffer, ref int offset, int count) + { + byte[] result = new byte[count]; + Buffer.BlockCopy(buffer, offset, result, 0, count); + offset += count; + return result; + } + + static public short ReadShort(byte[] buffer, int offset) + { + return ReadShort(buffer, ref offset); + } + + static public short ReadShort(byte[] buffer, ref int offset) + { + short ret = IPAddress.NetworkToHostOrder(BitConverter.ToInt16(buffer, offset)); + offset += 2; + return ret; + } + + static public string ReadString(byte[] buffer, int offset, int count) + { + return ReadString(buffer, ref offset, count); + } + + static public string ReadString(byte[] buffer, ref int offset, int count) + { + string s = System.Text.Encoding.ASCII.GetString(buffer, offset, count); + offset += count; + return s; + } + + static public int ReadInt(byte[] buffer, int offset) + { + return ReadInt(buffer, ref offset); + } + + static public int ReadInt(byte[] buffer, ref int offset) + { + int ret = IPAddress.NetworkToHostOrder(BitConverter.ToInt32(buffer, offset)); + offset += 4; + return ret; + } + + static public long ReadLong(byte[] buffer, int offset) + { + return ReadLong(buffer, ref offset); + } + + static public long ReadLong(byte[] buffer, ref int offset) + { + long ret = IPAddress.NetworkToHostOrder(BitConverter.ToInt64(buffer, offset)); + offset += 8; + return ret; + } + + static public int Write(byte[] buffer, int offset, byte value) + { + buffer[offset] = value; + return 1; + } + + static public int Write(byte[] dest, int destOffset, byte[] src, int srcOffset, int count) + { + Buffer.BlockCopy(src, srcOffset, dest, destOffset, count); + return count; + } + + static public int Write(byte[] buffer, int offset, ushort value) + { + return Write(buffer, offset, (short)value); + } + + static public int Write(byte[] buffer, int offset, short value) + { + offset += Write(buffer, offset, (byte)(value >> 8)); + offset += Write(buffer, offset, (byte)value); + return 2; + } + + static public int Write(byte[] buffer, int offset, int value) + { + offset += Write(buffer, offset, (byte)(value >> 24)); + offset += Write(buffer, offset, (byte)(value >> 16)); + offset += Write(buffer, offset, (byte)(value >> 8)); + offset += Write(buffer, offset, (byte)(value)); + return 4; + } + + static public int Write(byte[] buffer, int offset, uint value) + { + return Write(buffer, offset, (int)value); + } + + static public int Write(byte[] buffer, int offset, long value) + { + offset += Write(buffer, offset, (int)(value >> 32)); + offset += Write(buffer, offset, (int)value); + return 8; + } + + static public int Write(byte[] buffer, int offset, ulong value) + { + return Write(buffer, offset, (long)value); + } + + static public int Write(byte[] buffer, int offset, byte[] value) + { + return Write(buffer, offset, value, 0, value.Length); + } + + static public int WriteAscii(byte[] buffer, int offset, string text) + { + for (int i = 0; i < text.Length; i++) + Write(buffer, offset + i, (byte)text[i]); + return text.Length; + } + } +} \ No newline at end of file diff --git a/src/MonoTorrent/MonoTorrent.csproj b/src/MonoTorrent/MonoTorrent.csproj new file mode 100644 index 000000000..dd8fd6907 --- /dev/null +++ b/src/MonoTorrent/MonoTorrent.csproj @@ -0,0 +1,117 @@ + + + + Debug + x86 + Local + 9.0.21022 + 2.0 + {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8} + Library + MonoTorrent + MonoTorrent + + + JScript + Grid + IE50 + false + + + MonoTorrent + + + 3.5 + + + v4.0 + + 512 + publish\ + true + Disk + false + Foreground + 7 + Days + false + false + true + 0 + 1.0.0.%2a + false + false + true + ..\ + + + x86 + true + full + false + ..\..\_output\ + DEBUG;TRACE + prompt + 4 + + + x86 + pdbonly + true + ..\..\_output\ + TRACE + prompt + 4 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + False + .NET Framework 2.0 %28x86%29 + true + + + False + .NET Framework 3.0 %28x86%29 + false + + + False + .NET Framework 3.5 + false + + + + + + + + \ No newline at end of file diff --git a/src/MonoTorrent/RawTrackerTier.cs b/src/MonoTorrent/RawTrackerTier.cs new file mode 100644 index 000000000..43677fa4e --- /dev/null +++ b/src/MonoTorrent/RawTrackerTier.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections; +using System.Collections.Generic; + +using MonoTorrent.BEncoding; + +namespace MonoTorrent +{ + public class RawTrackerTier : IList + { + public string this[int index] { + get { return ((BEncodedString) Tier [index]).Text; } + set { Tier [index] = new BEncodedString (value );} + } + + internal BEncodedList Tier { + get; set; + } + + public RawTrackerTier () + : this (new BEncodedList ()) + { + } + + public RawTrackerTier (BEncodedList tier) + { + Tier = tier; + } + + public RawTrackerTier (IEnumerable announces) + : this () + { + foreach (var v in announces) + Add (v); + } + + public int IndexOf (string item) + { + return Tier.IndexOf ((BEncodedString) item); + } + + public void Insert (int index, string item) + { + Tier.Insert (index, (BEncodedString) item); + } + + public void RemoveAt (int index) + { + Tier.RemoveAt (index); + } + + public void Add (string item) + { + Tier.Add ((BEncodedString) item); + } + + public void Clear () + { + Tier.Clear (); + } + + public bool Contains (string item) + { + return Tier.Contains ((BEncodedString) item); + } + + public void CopyTo (string[] array, int arrayIndex) + { + foreach (var s in this) + array [arrayIndex ++] = s; + } + + public bool Remove (string item) + { + return Tier.Remove ((BEncodedString) item); + } + + public int Count { + get { return Tier.Count; } + } + + public bool IsReadOnly { + get { return Tier.IsReadOnly; } + } + + public IEnumerator GetEnumerator () + { + foreach (BEncodedString v in Tier) + yield return v.Text; + } + + IEnumerator IEnumerable.GetEnumerator () + { + return GetEnumerator (); + } + } +} diff --git a/src/MonoTorrent/RawTrackerTiers.cs b/src/MonoTorrent/RawTrackerTiers.cs new file mode 100644 index 000000000..5ea50e3ab --- /dev/null +++ b/src/MonoTorrent/RawTrackerTiers.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections; +using System.Collections.Generic; + +using MonoTorrent.BEncoding; + +namespace MonoTorrent +{ + public class RawTrackerTiers : IList + { + BEncodedList Tiers { + get; set; + } + + public RawTrackerTiers () + : this (new BEncodedList ()) + { + } + + public RawTrackerTiers (BEncodedList tiers) + { + Tiers = tiers; + } + + public int IndexOf (RawTrackerTier item) + { + if (item != null) { + for (int i = 0; i < Tiers.Count; i++) + if (item.Tier == Tiers [i]) + return i; + } + return -1; + } + + public void Insert (int index, RawTrackerTier item) + { + Tiers.Insert (index, item.Tier); + } + + public void RemoveAt (int index) + { + Tiers.RemoveAt (index); + } + + public RawTrackerTier this[int index] { + get { return new RawTrackerTier ((BEncodedList) Tiers [index]); } + set { Tiers [index] = value.Tier; } + } + + public void Add (RawTrackerTier item) + { + Tiers.Add (item.Tier); + } + + public void AddRange (IEnumerable tiers) + { + foreach (var v in tiers) + Add (v); + } + + public void Clear () + { + Tiers.Clear (); + } + + public bool Contains (RawTrackerTier item) + { + return IndexOf (item) != -1; + } + + public void CopyTo (RawTrackerTier[] array, int arrayIndex) + { + foreach (var v in this) + array [arrayIndex ++] = v; + } + + public bool Remove (RawTrackerTier item) + { + int index = IndexOf (item); + if (index != -1) + RemoveAt (index); + + return index != -1; + } + + public int Count { + get { return Tiers.Count; } + } + + public bool IsReadOnly { + get { return Tiers.IsReadOnly; } + } + + public IEnumerator GetEnumerator () + { + foreach (var v in Tiers) + yield return new RawTrackerTier ((BEncodedList) v); + } + + IEnumerator IEnumerable.GetEnumerator () + { + return GetEnumerator (); + } + } +} diff --git a/src/MonoTorrent/ToolBox.cs b/src/MonoTorrent/ToolBox.cs new file mode 100644 index 000000000..b4116d716 --- /dev/null +++ b/src/MonoTorrent/ToolBox.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections; +using System.Text; +using System.Collections.Generic; +using System.Threading; + +namespace MonoTorrent.Common +{ + public delegate long Operation(T target); + + public static class Toolbox + { + private static Random r = new Random(); + public static int Count(IEnumerable enumerable, Predicate predicate) + { + int count = 0; + + foreach (T t in enumerable) + if (predicate(t)) + count++; + + return count; + } + + public static long Accumulate(IEnumerable enumerable, Operation action) + { + long count = 0; + + foreach (T t in enumerable) + count += action(t); + + return count; + } + + public static void RaiseAsyncEvent(EventHandler e, object o, T args) + where T : EventArgs + { + if (e == null) + return; + + ThreadPool.QueueUserWorkItem(delegate { + if (e != null) + e(o, args); + }); + } + /// + /// Randomizes the contents of the array + /// + /// + /// + public static void Randomize(List array) + { + List clone = new List(array); + array.Clear(); + + while (clone.Count > 0) + { + int index = r.Next(0, clone.Count); + array.Add(clone[index]); + clone.RemoveAt(index); + } + } + + /// + /// Switches the positions of two elements in an array + /// + /// + /// + /// + /// + public static void Switch(IList array, int first, int second) + { + T obj = array[first]; + array[first] = array[second]; + array[second] = obj; + } + + /// + /// Checks to see if the contents of two byte arrays are equal + /// + /// The first array + /// The second array + /// True if the arrays are equal, false if they aren't + public static bool ByteMatch(byte[] array1, byte[] array2) + { + if (array1 == null) + throw new ArgumentNullException("array1"); + if (array2 == null) + throw new ArgumentNullException("array2"); + + if (array1.Length != array2.Length) + return false; + + return ByteMatch(array1, 0, array2, 0, array1.Length); + } + + /// + /// Checks to see if the contents of two byte arrays are equal + /// + /// The first array + /// The second array + /// The starting index for the first array + /// The starting index for the second array + /// The number of bytes to check + /// + public static bool ByteMatch(byte[] array1, int offset1, byte[] array2, int offset2, int count) + { + if (array1 == null) + throw new ArgumentNullException("array1"); + if (array2 == null) + throw new ArgumentNullException("array2"); + + // If either of the arrays is too small, they're not equal + if ((array1.Length - offset1) < count || (array2.Length - offset2) < count) + return false; + + // Check if any elements are unequal + for (int i = 0; i < count; i++) + if (array1[offset1 + i] != array2[offset2 + i]) + return false; + + return true; + } + } +} diff --git a/src/MonoTorrent/Torrent.cs b/src/MonoTorrent/Torrent.cs new file mode 100644 index 000000000..59a1f3253 --- /dev/null +++ b/src/MonoTorrent/Torrent.cs @@ -0,0 +1,885 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Security.Cryptography; +using System.Text; +using MonoTorrent.BEncoding; +using MonoTorrent.Common; + +namespace MonoTorrent +{ + /// + /// The "Torrent" class for both Tracker and Client should inherit from this + /// as it contains the fields that are common to both. + /// + public class Torrent : IEquatable + { + #region Private Fields + + private BEncodedDictionary originalDictionary; + private BEncodedValue azureusProperties; + private IList announceUrls; + private string comment; + private string createdBy; + private DateTime creationDate; + private byte[] ed2k; + private string encoding; + internal InfoHash infoHash; + private bool isPrivate; + protected string name; + private BEncodedList nodes; + protected int pieceLength; + protected Hashes pieces; + private string publisher; + private string publisherUrl; + private byte[] sha1; + protected long size; + private string source; + protected TorrentFile[] torrentFiles; + protected string torrentPath; + private List getRightHttpSeeds; + private byte[] metadata; + + #endregion Private Fields + + + #region Properties + + internal byte[] Metadata + { + get { return this.metadata; } + } + + /// + /// The announce URLs contained within the .torrent file + /// + public IList AnnounceUrls + { + get { return this.announceUrls; } + } + + + /// + /// This dictionary is specific for azureus client + /// It can contain + /// dht_backup_enable (number) + /// Content (dictionnary) + /// Publisher + /// Description + /// Title + /// Creation Date + /// Content Hash + /// Revision Date + /// Thumbnail (string) = Base64 encoded image + /// Progressive + /// Speed Bps (number) + /// but not useful for MT + /// + public BEncodedValue AzureusProperties + { + get { return this.azureusProperties; } + } + + + /// + /// The comment contained within the .torrent file + /// + public string Comment + { + get { return this.comment; } + } + + + /// + /// The optional string showing who/what created the .torrent + /// + public string CreatedBy + { + get { return this.createdBy; } + } + + + /// + /// The creation date of the .torrent file + /// + public DateTime CreationDate + { + get { return this.creationDate; } + } + + + /// + /// The optional ED2K hash contained within the .torrent file + /// + public byte[] ED2K + { + get { return this.ed2k; } + } + + + /// + /// The encoding used by the client that created the .torrent file + /// + public string Encoding + { + get { return this.encoding; } + } + + + /// + /// The list of files contained within the .torrent which are available for download + /// + public TorrentFile[] Files + { + get { return this.torrentFiles; } + } + + + /// + /// This is the infohash that is generated by putting the "Info" section of a .torrent + /// through a ManagedSHA1 hasher. + /// + public InfoHash InfoHash + { + get { return this.infoHash; } + } + + + /// + /// Shows whether DHT is allowed or not. If it is a private torrent, no peer + /// sharing should be allowed. + /// + public bool IsPrivate + { + get { return this.isPrivate; } + } + + + /// + /// In the case of a single file torrent, this is the name of the file. + /// In the case of a multi file torrent, it is the name of the root folder. + /// + public string Name + { + get { return this.name; } + private set { this.name = value; } + } + + + /// + /// FIXME: No idea what this is. + /// + public BEncodedList Nodes + { + get { return this.nodes; } + } + + + /// + /// The length of each piece in bytes. + /// + public int PieceLength + { + get { return this.pieceLength; } + } + + + /// + /// This is the array of hashes contained within the torrent. + /// + public Hashes Pieces + { + get { return this.pieces; } + } + + + /// + /// The name of the Publisher + /// + public string Publisher + { + get { return this.publisher; } + } + + + /// + /// The Url of the publisher of either the content or the .torrent file + /// + public string PublisherUrl + { + get { return this.publisherUrl; } + } + + + /// + /// The optional SHA1 hash contained within the .torrent file + /// + public byte[] SHA1 + { + get { return this.sha1; } + } + + + /// + /// The total size of all the files that have to be downloaded. + /// + public long Size + { + get { return this.size; } + private set { this.size = value; } + } + + + /// + /// The source of the .torrent file + /// + public string Source + { + get { return this.source; } + } + + + /// + /// This is the path at which the .torrent file is located + /// + public string TorrentPath + { + get { return this.torrentPath; } + internal set { this.torrentPath = value; } + } + + /// + /// This is the http-based seeding (getright protocole) + /// + public List GetRightHttpSeeds + { + get { return this.getRightHttpSeeds; } + } + + #endregion Properties + + + #region Constructors + + protected Torrent() + { + this.announceUrls = new RawTrackerTiers (); + this.comment = string.Empty; + this.createdBy = string.Empty; + this.creationDate = new DateTime(1970, 1, 1, 0, 0, 0); + this.encoding = string.Empty; + this.name = string.Empty; + this.publisher = string.Empty; + this.publisherUrl = string.Empty; + this.source = string.Empty; + this.getRightHttpSeeds = new List(); + } + + #endregion + + + #region Public Methods + + public override bool Equals(object obj) + { + return this.Equals(obj as Torrent); + } + + public bool Equals(Torrent other) + { + if (other == null) + return false; + + return this.infoHash == other.infoHash; + } + + public override int GetHashCode() + { + return this.infoHash.GetHashCode(); + } + + internal byte [] ToBytes () + { + return this.originalDictionary.Encode (); + } + + internal BEncodedDictionary ToDictionary () + { + // Give the user a copy of the original dictionary. + return BEncodedValue.Clone (this.originalDictionary); + } + + public override string ToString() + { + return this.name; + } + + #endregion Public Methods + + + #region Private Methods + + /// + /// This method is called internally to read out the hashes from the info section of the + /// .torrent file. + /// + /// The byte[]containing the hashes from the .torrent file + private void LoadHashPieces(byte[] data) + { + if (data.Length % 20 != 0) + throw new TorrentException("Invalid infohash detected"); + + this.pieces = new Hashes(data, data.Length / 20); + } + + + /// + /// This method is called internally to load in all the files found within the "Files" section + /// of the .torrents infohash + /// + /// The list containing the files available to download + private void LoadTorrentFiles(BEncodedList list) + { + List files = new List(); + int endIndex; + long length; + string path; + byte[] md5sum; + byte[] ed2k; + byte[] sha1; + int startIndex; + StringBuilder sb = new StringBuilder(32); + + foreach (BEncodedDictionary dict in list) + { + length = 0; + path = null; + md5sum = null; + ed2k = null; + sha1 = null; + + foreach (KeyValuePair keypair in dict) + { + switch (keypair.Key.Text) + { + case ("sha1"): + sha1 = ((BEncodedString)keypair.Value).TextBytes; + break; + + case ("ed2k"): + ed2k = ((BEncodedString)keypair.Value).TextBytes; + break; + + case ("length"): + length = long.Parse(keypair.Value.ToString()); + break; + + case ("path.utf-8"): + foreach (BEncodedString str in ((BEncodedList)keypair.Value)) + { + sb.Append(str.Text); + sb.Append(Path.DirectorySeparatorChar); + } + path = sb.ToString(0, sb.Length - 1); + sb.Remove(0, sb.Length); + break; + + case ("path"): + if (string.IsNullOrEmpty(path)) + { + foreach (BEncodedString str in ((BEncodedList)keypair.Value)) + { + sb.Append(str.Text); + sb.Append(Path.DirectorySeparatorChar); + } + path = sb.ToString(0, sb.Length - 1); + sb.Remove(0, sb.Length); + } + break; + + case ("md5sum"): + md5sum = ((BEncodedString)keypair.Value).TextBytes; + break; + + default: + break; //FIXME: Log unknown values + } + } + + // A zero length file always belongs to the same piece as the previous file + if (length == 0) + { + if (files.Count > 0) + { + startIndex = files[files.Count - 1].EndPieceIndex; + endIndex = files[files.Count - 1].EndPieceIndex; + } + else + { + startIndex = 0; + endIndex = 0; + } + } + else + { + startIndex = (int)(this.size / this.pieceLength); + endIndex = (int)((this.size + length) / this.pieceLength); + if ((this.size + length) % this.pieceLength == 0) + endIndex--; + } + this.size += length; + files.Add(new TorrentFile(path, length, path, startIndex, endIndex, md5sum, ed2k, sha1)); + } + + this.torrentFiles = files.ToArray(); + } + + + /// + /// This method is called internally to load the information found within the "Info" section + /// of the .torrent file + /// + /// The dictionary representing the Info section of the .torrent file + private void ProcessInfo(BEncodedDictionary dictionary) + { + this.metadata = dictionary.Encode(); + this.pieceLength = int.Parse(dictionary["piece length"].ToString()); + this.LoadHashPieces(((BEncodedString)dictionary["pieces"]).TextBytes); + + foreach (KeyValuePair keypair in dictionary) + { + switch (keypair.Key.Text) + { + case ("source"): + this.source = keypair.Value.ToString(); + break; + + case ("sha1"): + this.sha1 = ((BEncodedString)keypair.Value).TextBytes; + break; + + case ("ed2k"): + this.ed2k = ((BEncodedString)keypair.Value).TextBytes; + break; + + case ("publisher-url.utf-8"): + if (keypair.Value.ToString().Length > 0) + this.publisherUrl = keypair.Value.ToString(); + break; + + case ("publisher-url"): + if ((String.IsNullOrEmpty(this.publisherUrl)) && (keypair.Value.ToString().Length > 0)) + this.publisherUrl = keypair.Value.ToString(); + break; + + case ("publisher.utf-8"): + if (keypair.Value.ToString().Length > 0) + this.publisher = keypair.Value.ToString(); + break; + + case ("publisher"): + if ((String.IsNullOrEmpty(this.publisher)) && (keypair.Value.ToString().Length > 0)) + this.publisher = keypair.Value.ToString(); + break; + + case ("files"): + this.LoadTorrentFiles(((BEncodedList)keypair.Value)); + break; + + case ("name.utf-8"): + if (keypair.Value.ToString().Length > 0) + this.name = keypair.Value.ToString(); + break; + + case ("name"): + if ((String.IsNullOrEmpty(this.name)) && (keypair.Value.ToString().Length > 0)) + this.name = keypair.Value.ToString(); + break; + + case ("piece length"): // Already handled + break; + + case ("length"): + break; // This is a singlefile torrent + + case ("private"): + this.isPrivate = (keypair.Value.ToString() == "1") ? true : false; + break; + + default: + break; + } + } + + if (this.torrentFiles == null) // Not a multi-file torrent + { + long length = long.Parse(dictionary["length"].ToString()); + this.size = length; + string path = this.name; + byte[] md5 = (dictionary.ContainsKey("md5")) ? ((BEncodedString)dictionary["md5"]).TextBytes : null; + byte[] ed2k = (dictionary.ContainsKey("ed2k")) ? ((BEncodedString)dictionary["ed2k"]).TextBytes : null; + byte[] sha1 = (dictionary.ContainsKey("sha1")) ? ((BEncodedString)dictionary["sha1"]).TextBytes : null; + + this.torrentFiles = new TorrentFile[1]; + int endPiece = Math.Min(this.Pieces.Count - 1, (int)((this.size + (this.pieceLength - 1)) / this.pieceLength)); + this.torrentFiles[0] = new TorrentFile(path, length, path, 0, endPiece, md5, ed2k, sha1); + } + } + + #endregion Private Methods + + + #region Loading methods + + /// + /// This method loads a .torrent file from the specified path. + /// + /// The path to load the .torrent file from + public static Torrent Load(string path) + { + Check.Path(path); + + using (Stream s = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) + return Torrent.Load(s, path); + } + + /// + /// Loads a torrent from a byte[] containing the bencoded data + /// + /// The byte[] containing the data + /// + public static Torrent Load(byte[] data) + { + Check.Data(data); + + using (MemoryStream s = new MemoryStream(data)) + return Load(s, ""); + } + + /// + /// Loads a .torrent from the supplied stream + /// + /// The stream containing the data to load + /// + public static Torrent Load(Stream stream) + { + Check.Stream(stream); + + if (stream == null) + throw new ArgumentNullException("stream"); + + return Torrent.Load(stream, ""); + } + + /// + /// Loads a .torrent file from the specified URL + /// + /// The URL to download the .torrent from + /// The path to download the .torrent to before it gets loaded + /// + public static Torrent Load(Uri url, string location) + { + Check.Url(url); + Check.Location(location); + + try + { + using (WebClient client = new WebClient()) + client.DownloadFile(url, location); + } + catch (Exception ex) + { + throw new TorrentException("Could not download .torrent file from the specified url", ex); + } + + return Torrent.Load(location); + } + + /// + /// Loads a .torrent from the specificed path. A return value indicates + /// whether the operation was successful. + /// + /// The path to load the .torrent file from + /// If the loading was succesful it is assigned the Torrent + /// True if successful + public static bool TryLoad(string path, out Torrent torrent) + { + Check.Path(path); + + try + { + torrent = Torrent.Load(path); + } + catch + { + torrent = null; + } + + return torrent != null; + } + + /// + /// Loads a .torrent from the specified byte[]. A return value indicates + /// whether the operation was successful. + /// + /// The byte[] to load the .torrent from + /// If loading was successful, it contains the Torrent + /// True if successful + public static bool TryLoad(byte[] data, out Torrent torrent) + { + Check.Data(data); + + try + { + torrent = Torrent.Load(data); + } + catch + { + torrent = null; + } + + return torrent != null; + } + + /// + /// Loads a .torrent from the supplied stream. A return value indicates + /// whether the operation was successful. + /// + /// The stream containing the data to load + /// If the loading was succesful it is assigned the Torrent + /// True if successful + public static bool TryLoad(Stream stream, out Torrent torrent) + { + Check.Stream(stream); + + try + { + torrent = Torrent.Load(stream); + } + catch + { + torrent = null; + } + + return torrent != null; + } + + /// + /// Loads a .torrent file from the specified URL. A return value indicates + /// whether the operation was successful. + /// + /// The URL to download the .torrent from + /// The path to download the .torrent to before it gets loaded + /// If the loading was succesful it is assigned the Torrent + /// True if successful + public static bool TryLoad(Uri url, string location, out Torrent torrent) + { + Check.Url(url); + Check.Location(location); + + try + { + torrent = Torrent.Load(url, location); + } + catch + { + torrent = null; + } + + return torrent != null; + } + + /// + /// Called from either Load(stream) or Load(string). + /// + /// + /// + /// + private static Torrent Load(Stream stream, string path) + { + Check.Stream(stream); + Check.Path(path); + + try + { + Torrent t = Torrent.LoadCore ((BEncodedDictionary) BEncodedDictionary.Decode(stream)); + t.torrentPath = path; + return t; + } + catch (BEncodingException ex) + { + throw new TorrentException("Invalid torrent file specified", ex); + } + } + + public static Torrent Load(BEncodedDictionary torrentInformation) + { + return LoadCore ((BEncodedDictionary)BEncodedValue.Decode (torrentInformation.Encode ())); + } + + internal static Torrent LoadCore(BEncodedDictionary torrentInformation) + { + Check.TorrentInformation(torrentInformation); + + Torrent t = new Torrent(); + t.LoadInternal(torrentInformation); + + return t; + } + + protected void LoadInternal(BEncodedDictionary torrentInformation) + { + Check.TorrentInformation(torrentInformation); + this.originalDictionary = torrentInformation; + this.torrentPath = ""; + + try + { + foreach (KeyValuePair keypair in torrentInformation) + { + switch (keypair.Key.Text) + { + case ("announce"): + // Ignore this if we have an announce-list + if (torrentInformation.ContainsKey("announce-list")) + break; + this.announceUrls.Add(new RawTrackerTier ()); + this.announceUrls[0].Add(keypair.Value.ToString()); + break; + + case ("creation date"): + try + { + try + { + this.creationDate = this.creationDate.AddSeconds(long.Parse(keypair.Value.ToString())); + } + catch (Exception e) + { + if (e is ArgumentOutOfRangeException) + this.creationDate = this.creationDate.AddMilliseconds(long.Parse(keypair.Value.ToString())); + else + throw; + } + } + catch (Exception e) + { + if (e is ArgumentOutOfRangeException) + throw new BEncodingException("Argument out of range exception when adding seconds to creation date.", e); + else if (e is FormatException) + throw new BEncodingException(String.Format("Could not parse {0} into a number", keypair.Value), e); + else + throw; + } + break; + + case ("nodes"): + this.nodes = (BEncodedList)keypair.Value; + break; + + case ("comment.utf-8"): + if (keypair.Value.ToString().Length != 0) + this.comment = keypair.Value.ToString(); // Always take the UTF-8 version + break; // even if there's an existing value + + case ("comment"): + if (String.IsNullOrEmpty(this.comment)) + this.comment = keypair.Value.ToString(); + break; + + case ("publisher-url.utf-8"): // Always take the UTF-8 version + this.publisherUrl = keypair.Value.ToString(); // even if there's an existing value + break; + + case ("publisher-url"): + if (String.IsNullOrEmpty(this.publisherUrl)) + this.publisherUrl = keypair.Value.ToString(); + break; + + case ("azureus_properties"): + this.azureusProperties = keypair.Value; + break; + + case ("created by"): + this.createdBy = keypair.Value.ToString(); + break; + + case ("encoding"): + this.encoding = keypair.Value.ToString(); + break; + + case ("info"): + using (SHA1 s = HashAlgoFactory.Create()) + this.infoHash = new InfoHash (s.ComputeHash(keypair.Value.Encode())); + this.ProcessInfo(((BEncodedDictionary)keypair.Value)); + break; + + case ("name"): // Handled elsewhere + break; + + case ("announce-list"): + if (keypair.Value is BEncodedString) + break; + BEncodedList announces = (BEncodedList)keypair.Value; + + for (int j = 0; j < announces.Count; j++) + { + if (announces[j] is BEncodedList) + { + BEncodedList bencodedTier = (BEncodedList)announces[j]; + List tier = new List(bencodedTier.Count); + + for (int k = 0; k < bencodedTier.Count; k++) + tier.Add(bencodedTier[k].ToString()); + + Toolbox.Randomize(tier); + + RawTrackerTier collection = new RawTrackerTier (); + for (int k = 0; k < tier.Count; k++) + collection.Add(tier[k]); + + if (collection.Count != 0) + this.announceUrls.Add(collection); + } + else + { + throw new BEncodingException(String.Format("Non-BEncodedList found in announce-list (found {0})", + announces[j].GetType())); + } + } + break; + + case ("httpseeds"): + // This form of web-seeding is not supported. + break; + + case ("url-list"): + if (keypair.Value is BEncodedString) + { + this.getRightHttpSeeds.Add(((BEncodedString)keypair.Value).Text); + } + else if (keypair.Value is BEncodedList) + { + foreach (BEncodedString str in (BEncodedList)keypair.Value) + this.GetRightHttpSeeds.Add(str.Text); + } + break; + + default: + break; + } + } + } + catch (Exception e) + { + if (e is BEncodingException) + throw; + else + throw new BEncodingException("", e); + } + } + + #endregion Loading methods + } +} diff --git a/src/MonoTorrent/TorrentException.cs b/src/MonoTorrent/TorrentException.cs new file mode 100644 index 000000000..05a0cc05d --- /dev/null +++ b/src/MonoTorrent/TorrentException.cs @@ -0,0 +1,28 @@ +using System; + +namespace MonoTorrent +{ + [Serializable] + public class TorrentException : Exception + { + public TorrentException() + : base() + { + } + + public TorrentException(string message) + : base(message) + { + } + + public TorrentException(string message, Exception innerException) + : base(message, innerException) + { + } + + public TorrentException(System.Runtime.Serialization.SerializationInfo info, System.Runtime.Serialization.StreamingContext context) + : base(info, context) + { + } + } +} diff --git a/src/MonoTorrent/TorrentFile.cs b/src/MonoTorrent/TorrentFile.cs new file mode 100644 index 000000000..cff60141f --- /dev/null +++ b/src/MonoTorrent/TorrentFile.cs @@ -0,0 +1,205 @@ +using System; +using System.Text; + +namespace MonoTorrent +{ + /// + /// This is the base class for the files available to download from within a .torrent. + /// This should be inherited by both Client and Tracker "TorrentFile" classes + /// + public class TorrentFile : IEquatable + { + #region Private Fields + + private BitField bitfield; + private BitField selector; + private byte[] ed2k; + private int endPiece; + private string fullPath; + private long length; + private byte[] md5; + private string path; + private Priority priority; + private byte[] sha1; + private int startPiece; + + #endregion Private Fields + + + #region Member Variables + + /// + /// The number of pieces which have been successfully downloaded which are from this file + /// + public BitField BitField + { + get { return this.bitfield; } + } + + public long BytesDownloaded + { + get { return (long)(this.BitField.PercentComplete * this.Length / 100.0); } + } + + /// + /// The ED2K hash of the file + /// + public byte[] ED2K + { + get { return this.ed2k; } + } + + /// + /// The index of the last piece of this file + /// + public int EndPieceIndex + { + get { return this.endPiece; } + } + + public string FullPath + { + get { return this.fullPath; } + internal set { this.fullPath = value; } + } + + /// + /// The length of the file in bytes + /// + public long Length + { + get { return this.length; } + } + + /// + /// The MD5 hash of the file + /// + public byte[] MD5 + { + get { return this.md5; } + internal set { this.md5 = value; } + } + + /// + /// In the case of a single torrent file, this is the name of the file. + /// In the case of a multi-file torrent this is the relative path of the file + /// (including the filename) from the base directory + /// + public string Path + { + get { return this.path; } + } + + /// + /// The priority of this torrent file + /// + public Priority Priority + { + get { return this.priority; } + set { this.priority = value; } + } + + /// + /// The SHA1 hash of the file + /// + public byte[] SHA1 + { + get { return this.sha1; } + } + + /// + /// The index of the first piece of this file + /// + public int StartPieceIndex + { + get { return this.startPiece; } + } + + #endregion + + + #region Constructors + public TorrentFile(string path, long length) + : this(path, length, path) + { + + } + + public TorrentFile (string path, long length, string fullPath) + : this (path, length, fullPath, 0, 0) + { + + } + + public TorrentFile (string path, long length, int startIndex, int endIndex) + : this (path, length, path, startIndex, endIndex) + { + + } + + public TorrentFile(string path, long length, string fullPath, int startIndex, int endIndex) + : this(path, length, fullPath, startIndex, endIndex, null, null, null) + { + + } + + public TorrentFile(string path, long length, string fullPath, int startIndex, int endIndex, byte[] md5, byte[] ed2k, byte[] sha1) + { + this.bitfield = new BitField(endIndex - startIndex + 1); + this.ed2k = ed2k; + this.endPiece = endIndex; + this.fullPath = fullPath; + this.length = length; + this.md5 = md5; + this.path = path; + this.priority = Priority.Normal; + this.sha1 = sha1; + this.startPiece = startIndex; + } + + #endregion + + + #region Methods + + public override bool Equals(object obj) + { + return this.Equals(obj as TorrentFile); + } + + public bool Equals(TorrentFile other) + { + return other == null ? false : this.path == other.path && this.length == other.length; ; + } + + public override int GetHashCode() + { + return this.path.GetHashCode(); + } + + internal BitField GetSelector(int totalPieces) + { + if (this.selector != null) + return this.selector; + + this.selector = new BitField(totalPieces); + for (int i = this.StartPieceIndex; i <= this.EndPieceIndex; i++) + this.selector[i] = true; + return this.selector; + } + + public override string ToString() + { + StringBuilder sb = new StringBuilder(32); + sb.Append("File: "); + sb.Append(this.path); + sb.Append(" StartIndex: "); + sb.Append(this.StartPieceIndex); + sb.Append(" EndIndex: "); + sb.Append(this.EndPieceIndex); + return sb.ToString(); + } + + #endregion Methods + } +} \ No newline at end of file diff --git a/src/MonoTorrent/UriHelper.cs b/src/MonoTorrent/UriHelper.cs new file mode 100644 index 000000000..d806e596a --- /dev/null +++ b/src/MonoTorrent/UriHelper.cs @@ -0,0 +1,153 @@ +// +// System.Web.HttpUtility/HttpEncoder +// +// Authors: +// Patrik Torstensson (Patrik.Torstensson@labs2.com) +// Wictor Wilén (decode/encode functions) (wictor@ibizkit.se) +// Tim Coleman (tim@timcoleman.com) +using System; +using System.Text; +using System.IO; +using System.Collections.Generic; + +namespace MonoTorrent +{ + static class UriHelper + { + static readonly char [] hexChars = "0123456789abcdef".ToCharArray (); + + public static string UrlEncode (byte[] bytes) + { + if (bytes == null) + throw new ArgumentNullException ("bytes"); + + var result = new MemoryStream (bytes.Length); + for (int i = 0; i < bytes.Length; i++) + UrlEncodeChar ((char)bytes [i], result, false); + + return Encoding.ASCII.GetString (result.ToArray()); + } + + public static byte [] UrlDecode (string s) + { + if (null == s) + return null; + + var e = Encoding.UTF8; + if (s.IndexOf ('%') == -1 && s.IndexOf ('+') == -1) + return e.GetBytes (s); + + long len = s.Length; + var bytes = new List (); + int xchar; + char ch; + + for (int i = 0; i < len; i++) { + ch = s [i]; + if (ch == '%' && i + 2 < len && s [i + 1] != '%') { + if (s [i + 1] == 'u' && i + 5 < len) { + // unicode hex sequence + xchar = GetChar (s, i + 2, 4); + if (xchar != -1) { + WriteCharBytes (bytes, (char)xchar, e); + i += 5; + } else + WriteCharBytes (bytes, '%', e); + } else if ((xchar = GetChar (s, i + 1, 2)) != -1) { + WriteCharBytes (bytes, (char)xchar, e); + i += 2; + } else { + WriteCharBytes (bytes, '%', e); + } + continue; + } + + if (ch == '+') + WriteCharBytes (bytes, ' ', e); + else + WriteCharBytes (bytes, ch, e); + } + + return bytes.ToArray (); + } + + static void UrlEncodeChar (char c, Stream result, bool isUnicode) { + if (c > ' ' && NotEncoded (c)) { + result.WriteByte ((byte)c); + return; + } + if (c==' ') { + result.WriteByte ((byte)'+'); + return; + } + if ( (c < '0') || + (c < 'A' && c > '9') || + (c > 'Z' && c < 'a') || + (c > 'z')) { + if (isUnicode && c > 127) { + result.WriteByte ((byte)'%'); + result.WriteByte ((byte)'u'); + result.WriteByte ((byte)'0'); + result.WriteByte ((byte)'0'); + } + else + result.WriteByte ((byte)'%'); + + int idx = ((int) c) >> 4; + result.WriteByte ((byte)hexChars [idx]); + idx = ((int) c) & 0x0F; + result.WriteByte ((byte)hexChars [idx]); + } + else { + result.WriteByte ((byte)c); + } + } + + static int GetChar (string str, int offset, int length) + { + int val = 0; + int end = length + offset; + for (int i = offset; i < end; i++) { + char c = str [i]; + if (c > 127) + return -1; + + int current = GetInt ((byte) c); + if (current == -1) + return -1; + val = (val << 4) + current; + } + + return val; + } + + static int GetInt (byte b) + { + char c = (char) b; + if (c >= '0' && c <= '9') + return c - '0'; + + if (c >= 'a' && c <= 'f') + return c - 'a' + 10; + + if (c >= 'A' && c <= 'F') + return c - 'A' + 10; + + return -1; + } + + static bool NotEncoded (char c) + { + return c == '!' || c == '(' || c == ')' || c == '*' || c == '-' || c == '.' || c == '_' || c == '\''; + } + + static void WriteCharBytes (List buf, char ch, Encoding e) + { + if (ch > 255) { + foreach (byte b in e.GetBytes (new char[] { ch })) + buf.Add (b); + } else + buf.Add ((byte)ch); + } + } +} diff --git a/src/NzbDrone.Api/Config/MediaManagementConfigResource.cs b/src/NzbDrone.Api/Config/MediaManagementConfigResource.cs index 23eeef97c..f5724489c 100644 --- a/src/NzbDrone.Api/Config/MediaManagementConfigResource.cs +++ b/src/NzbDrone.Api/Config/MediaManagementConfigResource.cs @@ -19,5 +19,6 @@ namespace NzbDrone.Api.Config public String ChownGroup { get; set; } public Boolean SkipFreeSpaceCheckWhenImporting { get; set; } + public Boolean CopyUsingHardlinks { get; set; } } } diff --git a/src/NzbDrone.Common.Test/DiskProviderTests/DiskProviderFixtureBase.cs b/src/NzbDrone.Common.Test/DiskProviderTests/DiskProviderFixtureBase.cs index dd463e1ac..6f36cf59b 100644 --- a/src/NzbDrone.Common.Test/DiskProviderTests/DiskProviderFixtureBase.cs +++ b/src/NzbDrone.Common.Test/DiskProviderTests/DiskProviderFixtureBase.cs @@ -162,6 +162,72 @@ namespace NzbDrone.Common.Test.DiskProviderTests Directory.Exists(sourceDir).Should().BeFalse(); } + [Test] + public void should_be_able_to_hardlink_file() + { + var sourceDir = GetTempFilePath(); + var source = Path.Combine(sourceDir, "test.txt"); + var destination = Path.Combine(sourceDir, "destination.txt"); + + Directory.CreateDirectory(sourceDir); + + Subject.WriteAllText(source, "SourceFile"); + + var result = Subject.TransferFile(source, destination, TransferMode.HardLink); + + result.Should().Be(TransferMode.HardLink); + + File.AppendAllText(source, "Test"); + File.ReadAllText(destination).Should().Be("SourceFileTest"); + } + + private void DoHardLinkRename(FileShare fileShare) + { + var sourceDir = GetTempFilePath(); + var source = Path.Combine(sourceDir, "test.txt"); + var destination = Path.Combine(sourceDir, "destination.txt"); + var rename = Path.Combine(sourceDir, "rename.txt"); + + Directory.CreateDirectory(sourceDir); + + Subject.WriteAllText(source, "SourceFile"); + + Subject.TransferFile(source, destination, TransferMode.HardLink); + + using (var stream = new FileStream(source, FileMode.Open, FileAccess.Read, fileShare)) + { + stream.ReadByte(); + + Subject.MoveFile(destination, rename); + + stream.ReadByte(); + } + + File.Exists(rename).Should().BeTrue(); + File.Exists(destination).Should().BeFalse(); + + File.AppendAllText(source, "Test"); + File.ReadAllText(rename).Should().Be("SourceFileTest"); + } + + [Test] + public void should_be_able_to_rename_open_hardlinks_with_fileshare_delete() + { + DoHardLinkRename(FileShare.Delete); + } + + [Test] + public void should_not_be_able_to_rename_open_hardlinks_with_fileshare_none() + { + Assert.Throws(() => DoHardLinkRename(FileShare.None)); + } + + [Test] + public void should_not_be_able_to_rename_open_hardlinks_with_fileshare_write() + { + Assert.Throws(() => DoHardLinkRename(FileShare.Read)); + } + [Test] public void empty_folder_should_return_folder_modified_date() { diff --git a/src/NzbDrone.Common.Test/InstrumentationTests/CleanseLogMessageFixture.cs b/src/NzbDrone.Common.Test/InstrumentationTests/CleanseLogMessageFixture.cs index e78d7b70f..069652ed6 100644 --- a/src/NzbDrone.Common.Test/InstrumentationTests/CleanseLogMessageFixture.cs +++ b/src/NzbDrone.Common.Test/InstrumentationTests/CleanseLogMessageFixture.cs @@ -11,12 +11,16 @@ namespace NzbDrone.Common.Test.InstrumentationTests [TestFixture] public class CleanseLogMessageFixture { - [TestCase(@"http://127.0.0.1:1234/api/call?vv=1&apikey=mySecret")] - [TestCase(@"http://127.0.0.1:1234/api/call?vv=1&ma_username=mySecret&ma_password=mySecret")] + // Indexer Urls + [TestCase(@"https://iptorrents.com/torrents/rss?u=mySecret;tp=mySecret;l5;download")] + [TestCase(@"http://rss.torrentleech.org/mySecret")] + [TestCase(@"http://www.bitmetv.org/rss.php?uid=mySecret&passkey=mySecret")] // NzbGet [TestCase(@"{ ""Name"" : ""ControlUsername"", ""Value"" : ""mySecret"" }, { ""Name"" : ""ControlPassword"", ""Value"" : ""mySecret"" }, ")] [TestCase(@"{ ""Name"" : ""Server1.Username"", ""Value"" : ""mySecret"" }, { ""Name"" : ""Server1.Password"", ""Value"" : ""mySecret"" }, ")] // Sabnzbd + [TestCase(@"http://127.0.0.1:1234/api/call?vv=1&apikey=mySecret")] + [TestCase(@"http://127.0.0.1:1234/api/call?vv=1&ma_username=mySecret&ma_password=mySecret")] [TestCase(@"""config"":{""newzbin"":{""username"":""mySecret"",""password"":""mySecret""}")] [TestCase(@"""nzbxxx"":{""username"":""mySecret"",""apikey"":""mySecret""}")] [TestCase(@"""growl"":{""growl_password"":""mySecret"",""growl_server"":""""}")] @@ -24,6 +28,19 @@ namespace NzbDrone.Common.Test.InstrumentationTests [TestCase(@"""misc"":{""username"":""mySecret"",""api_key"":""mySecret"",""password"":""mySecret"",""nzb_key"":""mySecret""}")] [TestCase(@"""servers"":[{""username"":""mySecret"",""password"":""mySecret""}]")] [TestCase(@"""misc"":{""email_account"":""mySecret"",""email_to"":[],""email_from"":"""",""email_pwd"":""mySecret""}")] + // uTorrent + [TestCase(@"http://localhost:9091/gui/?token=wThmph5l0ZXfH-a6WOA4lqiLvyjCP0FpMrMeXmySecret_VXBO11HoKL751MAAAAA&list=1")] + [TestCase(@",[""boss_key"",0,""mySecret"",{""access"":""Y""}],[""boss_key_salt"",0,""mySecret"",{""access"":""W""}]")] + [TestCase(@",[""webui.username"",2,""mySecret"",{""access"":""Y""}],[""webui.password"",2,""mySecret"",{""access"":""Y""}]")] + [TestCase(@",[""webui.uconnect_username"",2,""mySecret"",{""access"":""Y""}],[""webui.uconnect_password"",2,""mySecret"",{""access"":""Y""}]")] + [TestCase(@",[""proxy.proxy"",2,""mySecret"",{""access"":""Y""}]")] + [TestCase(@",[""proxy.username"",2,""mySecret"",{""access"":""Y""}],[""proxy.password"",2,""mySecret"",{""access"":""Y""}]")] + // Deluge + [TestCase(@",{""download_location"": ""C:\Users\\mySecret mySecret\\Downloads""}")] + [TestCase(@",{""download_location"": ""/home/mySecret/Downloads""}")] + // BroadcastheNet + [TestCase(@"method: ""getTorrents"", ""params"": [ ""mySecret"",")] + [TestCase(@"""DownloadURL"":""https:\/\/broadcasthe.net\/torrents.php?action=download&id=123&authkey=mySecret&torrent_pass=mySecret""")] public void should_clean_message(String message) { var cleansedMessage = CleanseLogMessage.Cleanse(message); diff --git a/src/NzbDrone.Common.Test/NzbDrone.Common.Test.csproj b/src/NzbDrone.Common.Test/NzbDrone.Common.Test.csproj index f2e2d3429..a4199d64b 100644 --- a/src/NzbDrone.Common.Test/NzbDrone.Common.Test.csproj +++ b/src/NzbDrone.Common.Test/NzbDrone.Common.Test.csproj @@ -80,6 +80,7 @@ + diff --git a/src/NzbDrone.Common.Test/OsPathFixture.cs b/src/NzbDrone.Common.Test/OsPathFixture.cs new file mode 100644 index 000000000..1e58b7eb3 --- /dev/null +++ b/src/NzbDrone.Common.Test/OsPathFixture.cs @@ -0,0 +1,237 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using NUnit.Framework; +using NzbDrone.Common.Disk; +using NzbDrone.Test.Common; +using FluentAssertions; + +namespace NzbDrone.Common.Test +{ + public class OsPathFixture : TestBase + { + [TestCase(@"C:\rooted\windows\path\", OsPathKind.Windows)] + [TestCase(@"C:\rooted\windows\path", OsPathKind.Windows)] + [TestCase(@"C:\", OsPathKind.Windows)] + [TestCase(@"C:", OsPathKind.Windows)] + [TestCase(@"\\rooted\unc\path\", OsPathKind.Windows)] + [TestCase(@"\\rooted\unc\path", OsPathKind.Windows)] + [TestCase(@"\relative\windows\path\", OsPathKind.Windows)] + [TestCase(@"\relative\windows\path", OsPathKind.Windows)] + [TestCase(@"relative\windows\path\", OsPathKind.Windows)] + [TestCase(@"relative\windows\path", OsPathKind.Windows)] + [TestCase(@"relative\", OsPathKind.Windows)] + [TestCase(@"relative", OsPathKind.Unknown)] + [TestCase("/rooted/linux/path/", OsPathKind.Unix)] + [TestCase("/rooted/linux/path", OsPathKind.Unix)] + [TestCase("/", OsPathKind.Unix)] + [TestCase("linux/path", OsPathKind.Unix)] + public void should_auto_detect_kind(String path, OsPathKind kind) + { + var result = new OsPath(path); + + result.Kind.Should().Be(kind); + + if (kind == OsPathKind.Windows) + { + result.IsWindowsPath.Should().BeTrue(); + result.IsUnixPath.Should().BeFalse(); + } + else if (kind == OsPathKind.Unix) + { + result.IsWindowsPath.Should().BeFalse(); + result.IsUnixPath.Should().BeTrue(); + } + else + { + result.IsWindowsPath.Should().BeFalse(); + result.IsUnixPath.Should().BeFalse(); + } + } + + [Test] + public void should_add_directory_slash() + { + var osPath = new OsPath(@"C:\rooted\windows\path\"); + + osPath.Directory.Should().NotBeNull(); + osPath.Directory.ToString().Should().Be(@"C:\rooted\windows\"); + } + + [TestCase(@"C:\rooted\windows\path", @"C:\rooted\windows\")] + [TestCase(@"C:\rooted", @"C:\")] + [TestCase(@"C:", null)] + [TestCase("/rooted/linux/path", "/rooted/linux/")] + [TestCase("/rooted", "/")] + [TestCase("/", null)] + public void should_return_parent_directory(String path, String expectedParent) + { + var osPath = new OsPath(path); + + osPath.Directory.Should().NotBeNull(); + osPath.Directory.Should().Be(new OsPath(expectedParent)); + } + + [Test] + public void should_return_empty_as_parent_of_root_unc() + { + var osPath = new OsPath(@"\\unc"); + + osPath.Directory.IsEmpty.Should().BeTrue(); + } + + [TestCase(@"C:\rooted\windows\path")] + [TestCase(@"C:")] + [TestCase(@"\\blaat")] + [TestCase("/rooted/linux/path")] + [TestCase("/")] + public void should_detect_rooted_ospaths(String path) + { + var osPath = new OsPath(path); + + osPath.IsRooted.Should().BeTrue(); + } + + [TestCase(@"\rooted\windows\path")] + [TestCase(@"rooted\windows\path")] + [TestCase(@"path")] + [TestCase("linux/path")] + public void should_detect_unrooted_ospaths(String path) + { + var osPath = new OsPath(path); + + osPath.IsRooted.Should().BeFalse(); + } + + [TestCase(@"C:\rooted\windows\path", "path")] + [TestCase(@"C:", "C:")] + [TestCase(@"\\blaat", "blaat")] + [TestCase("/rooted/linux/path", "path")] + [TestCase("/", null)] + [TestCase(@"\rooted\windows\path\", "path")] + [TestCase(@"rooted\windows\path", "path")] + [TestCase(@"path", "path")] + [TestCase("linux/path", "path")] + public void should_return_filename(String path, String expectedFilePath) + { + var osPath = new OsPath(path); + + osPath.FileName.Should().Be(expectedFilePath); + } + + [Test] + public void should_compare_windows_ospathkind_case_insensitive() + { + var left = new OsPath(@"C:\rooted\Windows\path"); + var right = new OsPath(@"C:\rooted\windows\path"); + + left.Should().Be(right); + } + + [Test] + public void should_compare_unix_ospathkind_case_sensitive() + { + var left = new OsPath(@"/rooted/Linux/path"); + var right = new OsPath(@"/rooted/linux/path"); + + left.Should().NotBe(right); + } + + [Test] + public void should_not_ignore_trailing_slash_during_compare() + { + var left = new OsPath(@"/rooted/linux/path/"); + var right = new OsPath(@"/rooted/linux/path"); + + left.Should().NotBe(right); + } + + [TestCase(@"C:\Test", @"sub", @"C:\Test\sub")] + [TestCase(@"C:\Test", @"sub\test", @"C:\Test\sub\test")] + [TestCase(@"C:\Test\", @"\sub", @"C:\Test\sub")] + [TestCase(@"C:\Test", @"sub\", @"C:\Test\sub\")] + [TestCase(@"C:\Test", @"C:\Test2\sub", @"C:\Test2\sub")] + [TestCase(@"/Test", @"sub", @"/Test/sub")] + [TestCase(@"/Test", @"sub/", @"/Test/sub/")] + [TestCase(@"/Test", @"sub/", @"/Test/sub/")] + [TestCase(@"/Test/", @"sub/test/", @"/Test/sub/test/")] + [TestCase(@"/Test/", @"/Test2/", @"/Test2/")] + [TestCase(@"C:\Test", "", @"C:\Test")] + public void should_combine_path(String left, String right, String expectedResult) + { + var osPathLeft = new OsPath(left); + var osPathRight = new OsPath(right); + + var result = osPathLeft + osPathRight; + + result.FullPath.Should().Be(expectedResult); + } + + [Test] + public void should_fix_slashes_windows() + { + var osPath = new OsPath(@"C:/on/windows/transmission\uses/forward/slashes"); + + osPath.Kind.Should().Be(OsPathKind.Windows); + osPath.FullPath.Should().Be(@"C:\on\windows\transmission\uses\forward\slashes"); + } + + [Test] + public void should_fix_slashes_unix() + { + var osPath = new OsPath(@"/just/a/test\to\verify the/slashes\"); + + osPath.Kind.Should().Be(OsPathKind.Unix); + osPath.FullPath.Should().Be(@"/just/a/test/to/verify the/slashes/"); + } + + [Test] + public void should_combine_mixed_slashes() + { + var left = new OsPath(@"C:/on/windows/transmission"); + var right = new OsPath(@"uses/forward/slashes", OsPathKind.Unknown); + + var osPath = left + right; + + osPath.Kind.Should().Be(OsPathKind.Windows); + osPath.FullPath.Should().Be(@"C:\on\windows\transmission\uses\forward\slashes"); + } + + [TestCase(@"C:\Test\Data\", @"C:\Test\Data\Sub\Folder", @"Sub\Folder")] + [TestCase(@"C:\Test\Data\", @"C:\Test\Data2\Sub\Folder", @"..\Data2\Sub\Folder")] + [TestCase(@"/parent/folder", @"/parent/folder/Sub/Folder", @"Sub/Folder")] + public void should_create_relative_path(String parent, String child, String expected) + { + var left = new OsPath(child); + var right = new OsPath(parent); + + var osPath = left - right; + + osPath.Kind.Should().Be(OsPathKind.Unknown); + osPath.FullPath.Should().Be(expected); + } + + [Test] + public void should_parse_null_as_empty() + { + var result = new OsPath(null); + + result.FullPath.Should().BeEmpty(); + result.IsEmpty.Should().BeTrue(); + } + + [TestCase(@"C:\Test\", @"C:\Test", true)] + [TestCase(@"C:\Test\", @"C:\Test\Contains\", true)] + [TestCase(@"C:\Test\", @"C:\Other\", false)] + public void should_evaluate_contains(String parent, String child, Boolean expectedResult) + { + var left = new OsPath(parent); + var right = new OsPath(child); + + var result = left.Contains(right); + + result.Should().Be(expectedResult); + } + } +} diff --git a/src/NzbDrone.Common/DictionaryExtensions.cs b/src/NzbDrone.Common/DictionaryExtensions.cs index 22c6184ea..8c384fede 100644 --- a/src/NzbDrone.Common/DictionaryExtensions.cs +++ b/src/NzbDrone.Common/DictionaryExtensions.cs @@ -23,5 +23,10 @@ namespace NzbDrone.Common return merged; } + + public static void Add(this ICollection> collection, TKey key, TValue value) + { + collection.Add(key, value); + } } } diff --git a/src/NzbDrone.Common/Disk/DiskProviderBase.cs b/src/NzbDrone.Common/Disk/DiskProviderBase.cs index 8e8fbe0a9..e1ef09c0f 100644 --- a/src/NzbDrone.Common/Disk/DiskProviderBase.cs +++ b/src/NzbDrone.Common/Disk/DiskProviderBase.cs @@ -13,12 +13,6 @@ namespace NzbDrone.Common.Disk { public abstract class DiskProviderBase : IDiskProvider { - enum TransferAction - { - Copy, - Move - } - private static readonly Logger Logger = NzbDroneLogger.GetLogger(); public abstract long? GetAvailableSpace(string path); @@ -152,7 +146,7 @@ namespace NzbDrone.Common.Disk Ensure.That(source, () => source).IsValidPath(); Ensure.That(destination, () => destination).IsValidPath(); - TransferFolder(source, destination, TransferAction.Copy); + TransferFolder(source, destination, TransferMode.Copy); } public void MoveFolder(string source, string destination) @@ -162,7 +156,7 @@ namespace NzbDrone.Common.Disk try { - TransferFolder(source, destination, TransferAction.Move); + TransferFolder(source, destination, TransferMode.Move); DeleteFolder(source, true); } catch (Exception e) @@ -173,15 +167,15 @@ namespace NzbDrone.Common.Disk } } - private void TransferFolder(string source, string target, TransferAction transferAction) + public void TransferFolder(string source, string destination, TransferMode mode) { Ensure.That(source, () => source).IsValidPath(); - Ensure.That(target, () => target).IsValidPath(); + Ensure.That(destination, () => destination).IsValidPath(); - Logger.ProgressDebug("{0} {1} -> {2}", transferAction, source, target); + Logger.ProgressDebug("{0} {1} -> {2}", mode, source, destination); var sourceFolder = new DirectoryInfo(source); - var targetFolder = new DirectoryInfo(target); + var targetFolder = new DirectoryInfo(destination); if (!targetFolder.Exists) { @@ -190,28 +184,16 @@ namespace NzbDrone.Common.Disk foreach (var subDir in sourceFolder.GetDirectories()) { - TransferFolder(subDir.FullName, Path.Combine(target, subDir.Name), transferAction); + TransferFolder(subDir.FullName, Path.Combine(destination, subDir.Name), mode); } foreach (var sourceFile in sourceFolder.GetFiles("*.*", SearchOption.TopDirectoryOnly)) { - var destFile = Path.Combine(target, sourceFile.Name); + var destFile = Path.Combine(destination, sourceFile.Name); - Logger.ProgressDebug("{0} {1} -> {2}", transferAction, sourceFile, destFile); + Logger.ProgressDebug("{0} {1} -> {2}", mode, sourceFile, destFile); - switch (transferAction) - { - case TransferAction.Copy: - { - sourceFile.CopyTo(destFile, true); - break; - } - case TransferAction.Move: - { - MoveFile(sourceFile.FullName, destFile, true); - break; - } - } + TransferFile(sourceFile.FullName, destFile, mode, true); } } @@ -227,19 +209,15 @@ namespace NzbDrone.Common.Disk public void CopyFile(string source, string destination, bool overwrite = false) { - Ensure.That(source, () => source).IsValidPath(); - Ensure.That(destination, () => destination).IsValidPath(); - - if (source.PathEquals(destination)) - { - Logger.Warn("Source and destination can't be the same {0}", source); - return; - } - - File.Copy(source, destination, overwrite); + TransferFile(source, destination, TransferMode.Copy, overwrite); } public void MoveFile(string source, string destination, bool overwrite = false) + { + TransferFile(source, destination, TransferMode.Move, overwrite); + } + + public TransferMode TransferFile(string source, string destination, TransferMode mode, bool overwrite) { Ensure.That(source, () => source).IsValidPath(); Ensure.That(destination, () => destination).IsValidPath(); @@ -247,7 +225,7 @@ namespace NzbDrone.Common.Disk if (source.PathEquals(destination)) { Logger.Warn("Source and destination can't be the same {0}", source); - return; + return TransferMode.None; } if (FileExists(destination) && overwrite) @@ -255,10 +233,37 @@ namespace NzbDrone.Common.Disk DeleteFile(destination); } - RemoveReadOnly(source); - File.Move(source, destination); + if (mode.HasFlag(TransferMode.HardLink)) + { + bool createdHardlink = TryCreateHardLink(source, destination); + if (createdHardlink) + { + return TransferMode.HardLink; + } + else if (!mode.HasFlag(TransferMode.Copy)) + { + throw new IOException("Hardlinking from '" + source + "' to '" + destination + "' failed."); + } + } + + if (mode.HasFlag(TransferMode.Copy)) + { + File.Copy(source, destination, overwrite); + return TransferMode.Copy; + } + + if (mode.HasFlag(TransferMode.Move)) + { + RemoveReadOnly(source); + File.Move(source, destination); + return TransferMode.Move; + } + + return TransferMode.None; } + public abstract bool TryCreateHardLink(string source, string destination); + public void DeleteFolder(string path, bool recursive) { Ensure.That(path, () => path).IsValidPath(); diff --git a/src/NzbDrone.Common/Disk/IDiskProvider.cs b/src/NzbDrone.Common/Disk/IDiskProvider.cs index 1912b02ee..cc9934019 100644 --- a/src/NzbDrone.Common/Disk/IDiskProvider.cs +++ b/src/NzbDrone.Common/Disk/IDiskProvider.cs @@ -25,9 +25,12 @@ namespace NzbDrone.Common.Disk void CreateFolder(string path); void CopyFolder(string source, string destination); void MoveFolder(string source, string destination); + void TransferFolder(string source, string destination, TransferMode transferMode); void DeleteFile(string path); void CopyFile(string source, string destination, bool overwrite = false); void MoveFile(string source, string destination, bool overwrite = false); + TransferMode TransferFile(string source, string destination, TransferMode transferMode, bool overwrite = false); + bool TryCreateHardLink(string source, string destination); void DeleteFolder(string path, bool recursive); string ReadAllText(string filePath); void WriteAllText(string filename, string contents); diff --git a/src/NzbDrone.Common/Disk/OsPath.cs b/src/NzbDrone.Common/Disk/OsPath.cs new file mode 100644 index 000000000..540f70c25 --- /dev/null +++ b/src/NzbDrone.Common/Disk/OsPath.cs @@ -0,0 +1,406 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using NzbDrone.Common.Exceptions; + +namespace NzbDrone.Common.Disk +{ + public struct OsPath : IEquatable + { + private readonly String _path; + private readonly OsPathKind _kind; + + public OsPath(String path) + { + if (path == null) + { + _kind = OsPathKind.Unknown; + _path = String.Empty; + } + else + { + _kind = DetectPathKind(path); + _path = FixSlashes(path, _kind); + } + } + + public OsPath(String path, OsPathKind kind) + { + if (path == null) + { + _kind = kind; + _path = String.Empty; + } + else + { + _kind = kind; + _path = FixSlashes(path, kind); + } + } + + private static OsPathKind DetectPathKind(String path) + { + if (path.StartsWith("/")) + { + return OsPathKind.Unix; + } + if (path.Contains(':') || path.Contains('\\')) + { + return OsPathKind.Windows; + } + else if (path.Contains('/')) + { + return OsPathKind.Unix; + } + else + { + return OsPathKind.Unknown; + } + } + + private static String FixSlashes(String path, OsPathKind kind) + { + if (kind == OsPathKind.Windows) + { + return path.Replace('/', '\\'); + } + else if (kind == OsPathKind.Unix) + { + return path.Replace('\\', '/'); + } + + return path; + } + + public OsPathKind Kind + { + get { return _kind; } + } + + public Boolean IsWindowsPath + { + get { return _kind == OsPathKind.Windows; } + } + + public Boolean IsUnixPath + { + get { return _kind == OsPathKind.Unix; } + } + + public Boolean IsEmpty + { + get + { + return _path.IsNullOrWhiteSpace(); + } + } + + public Boolean IsRooted + { + get + { + if (IsWindowsPath) + { + return _path.StartsWith(@"\\") || _path.Contains(':'); + } + else if (IsUnixPath) + { + return _path.StartsWith("/"); + } + else + { + return false; + } + } + } + + public OsPath Directory + { + get + { + var index = GetFileNameIndex(); + + if (index == -1) + { + return new OsPath(null); + } + else + { + return new OsPath(_path.Substring(0, index), _kind).AsDirectory(); + } + } + } + + public String FullPath + { + get + { + return _path; + } + } + + public String FileName + { + get + { + var index = GetFileNameIndex(); + + if (index == -1) + { + var path = _path.Trim('\\', '/'); + + if (path.Length == 0) + { + return null; + } + + return path; + } + else + { + return _path.Substring(index).Trim('\\', '/'); + } + } + } + + private Int32 GetFileNameIndex() + { + if (_path.Length < 2) + { + return -1; + } + + var index = _path.LastIndexOfAny(new[] { '/', '\\' }, _path.Length - 2); + + if (index == -1) + { + return -1; + } + + if (_path.StartsWith(@"\\") && index < 2) + { + return -1; + } + + if (_path.StartsWith("/") && index == 0) + { + index++; + } + + return index; + } + + private String[] GetFragments() + { + return _path.Split(new char[] { '\\', '/' }, StringSplitOptions.RemoveEmptyEntries); + } + + public override String ToString() + { + return _path; + } + + public override Int32 GetHashCode() + { + return _path.ToLowerInvariant().GetHashCode(); + } + + public override Boolean Equals(Object obj) + { + if (obj is OsPath) + { + return Equals((OsPath)obj); + } + else if (obj is String) + { + return Equals(new OsPath(obj as String)); + } + else + { + return false; + } + } + + public OsPath AsDirectory() + { + if (IsEmpty) + { + return this; + } + + if (Kind == OsPathKind.Windows) + { + return new OsPath(_path.TrimEnd('\\') + "\\", _kind); + } + else if (Kind == OsPathKind.Unix) + { + return new OsPath(_path.TrimEnd('/') + "/", _kind); + } + else + { + return this; + } + } + + public Boolean Contains(OsPath other) + { + if (!IsRooted || !other.IsRooted) + { + return false; + } + + var leftFragments = GetFragments(); + var rightFragments = other.GetFragments(); + + if (rightFragments.Length < leftFragments.Length) + { + return false; + } + + var stringComparison = (Kind == OsPathKind.Windows || other.Kind == OsPathKind.Windows) ? StringComparison.InvariantCultureIgnoreCase : StringComparison.InvariantCulture; + + for (int i = 0; i < leftFragments.Length; i++) + { + if (!String.Equals(leftFragments[i], rightFragments[i], stringComparison)) + { + return false; + } + } + + return true; + } + + public Boolean Equals(OsPath other) + { + if (ReferenceEquals(other, null)) return false; + + if (_path == other._path) + { + return true; + } + + var left = _path; + var right = other._path; + + if (Kind == OsPathKind.Windows || other.Kind == OsPathKind.Windows) + { + return String.Equals(left, right, StringComparison.InvariantCultureIgnoreCase); + } + else + { + return String.Equals(left, right, StringComparison.InvariantCulture); + } + } + + public static Boolean operator ==(OsPath left, OsPath right) + { + if (ReferenceEquals(left, null)) return ReferenceEquals(right, null); + + return left.Equals(right); + } + + public static Boolean operator !=(OsPath left, OsPath right) + { + if (ReferenceEquals(left, null)) return !ReferenceEquals(right, null); + + return !left.Equals(right); + } + + public static OsPath operator +(OsPath left, OsPath right) + { + if (left.Kind != right.Kind && right.Kind != OsPathKind.Unknown) + { + throw new Exception(String.Format("Cannot combine OsPaths of different platforms ('{0}' + '{1}')", left, right)); + } + + if (right.IsEmpty) + { + return left; + } + + if (right.IsRooted) + { + return right; + } + + if (left.Kind == OsPathKind.Windows || right.Kind == OsPathKind.Windows) + { + return new OsPath(String.Join("\\", left._path.TrimEnd('\\'), right._path.TrimStart('\\')), OsPathKind.Windows); + } + else if (left.Kind == OsPathKind.Unix || right.Kind == OsPathKind.Unix) + { + return new OsPath(String.Join("/", left._path.TrimEnd('/'), right._path), OsPathKind.Unix); + } + else + { + return new OsPath(String.Join("/", left._path, right._path), OsPathKind.Unknown); + } + } + + public static OsPath operator +(OsPath left, String right) + { + return left + new OsPath(right); + } + + public static OsPath operator -(OsPath left, OsPath right) + { + if (!left.IsRooted || !right.IsRooted) + { + throw new ArgumentException("Cannot determine relative path for unrooted paths."); + } + + var leftFragments = left.GetFragments(); + var rightFragments = right.GetFragments(); + + var stringComparison = (left.Kind == OsPathKind.Windows || right.Kind == OsPathKind.Windows) ? StringComparison.InvariantCultureIgnoreCase : StringComparison.InvariantCulture; + + int i; + for (i = 0; i < leftFragments.Length && i < rightFragments.Length; i++) + { + if (!String.Equals(leftFragments[i], rightFragments[i], stringComparison)) + { + break; + } + } + + if (i == 0) + { + return right; + } + + var newFragments = new List(); + + for (int j = i; j < rightFragments.Length; j++) + { + newFragments.Add(".."); + } + + for (int j = i; j < leftFragments.Length; j++) + { + newFragments.Add(leftFragments[j]); + } + + if (left.FullPath.EndsWith("\\") || left.FullPath.EndsWith("/")) + { + newFragments.Add(String.Empty); + } + + if (left.Kind == OsPathKind.Windows || right.Kind == OsPathKind.Windows) + { + return new OsPath(String.Join("\\", newFragments), OsPathKind.Unknown); + } + else + { + return new OsPath(String.Join("/", newFragments), OsPathKind.Unknown); + } + } + } + + public enum OsPathKind + { + Unknown, + Windows, + Unix + } +} diff --git a/src/NzbDrone.Common/Disk/TransferMode.cs b/src/NzbDrone.Common/Disk/TransferMode.cs new file mode 100644 index 000000000..7b03db836 --- /dev/null +++ b/src/NzbDrone.Common/Disk/TransferMode.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Common.Disk +{ + [Flags] + public enum TransferMode + { + None = 0, + + Move = 1, + Copy = 2, + HardLink = 4, + + HardLinkOrCopy = Copy | HardLink + } +} diff --git a/src/NzbDrone.Common/Http/JsonRpcRequestBuilder.cs b/src/NzbDrone.Common/Http/JsonRpcRequestBuilder.cs new file mode 100644 index 000000000..328a4bdb0 --- /dev/null +++ b/src/NzbDrone.Common/Http/JsonRpcRequestBuilder.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using NzbDrone.Common.Serializer; + +namespace NzbDrone.Common.Http +{ + public class JsonRpcRequestBuilder : HttpRequestBuilder + { + public String Method { get; private set; } + public List Parameters { get; private set; } + + public JsonRpcRequestBuilder(String baseUri, String method, IEnumerable parameters) + : base (baseUri) + { + Method = method; + Parameters = parameters.ToList(); + } + + public override HttpRequest Build(String path) + { + var request = base.Build(path); + request.Method = HttpMethod.POST; + request.Headers.Accept = "application/json-rpc, application/json"; + request.Headers.ContentType = "application/json-rpc"; + + var message = new Dictionary(); + message["jsonrpc"] = "2.0"; + message["method"] = Method; + message["params"] = Parameters; + message["id"] = CreateNextId(); + + request.Body = message.ToJson(); + + return request; + } + + public String CreateNextId() + { + return Guid.NewGuid().ToString().Substring(0, 8); + } + } +} diff --git a/src/NzbDrone.Common/Http/JsonRpcResponse.cs b/src/NzbDrone.Common/Http/JsonRpcResponse.cs new file mode 100644 index 000000000..4a023a40b --- /dev/null +++ b/src/NzbDrone.Common/Http/JsonRpcResponse.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Newtonsoft.Json.Linq; +using NzbDrone.Common.Serializer; + +namespace NzbDrone.Common.Http +{ + public class JsonRpcResponse + { + public String Id { get; set; } + public T Result { get; set; } + public Object Error { get; set; } + } +} diff --git a/src/NzbDrone.Common/Instrumentation/CleanseLogMessage.cs b/src/NzbDrone.Common/Instrumentation/CleanseLogMessage.cs index 9befc8f1f..6f0f25d71 100644 --- a/src/NzbDrone.Common/Instrumentation/CleanseLogMessage.cs +++ b/src/NzbDrone.Common/Instrumentation/CleanseLogMessage.cs @@ -1,4 +1,5 @@ -using System.Text.RegularExpressions; +using System.Linq; +using System.Text.RegularExpressions; namespace NzbDrone.Common.Instrumentation { @@ -7,18 +8,30 @@ namespace NzbDrone.Common.Instrumentation private static readonly Regex[] CleansingRules = new[] { // Url - new Regex(@"(<=\?|&)apikey=(?\w+?)(?=\W|$|_)", RegexOptions.Compiled | RegexOptions.IgnoreCase), - new Regex(@"(<=\?|&)[^=]*?(username|password)=(?\w+?)(?=\W|$|_)", RegexOptions.Compiled | RegexOptions.IgnoreCase), - + new Regex(@"(?<=\?|&)(apikey|token|passkey|uid)=(?[^&=]+?)(?= |&|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new Regex(@"(?<=\?|&)[^=]*?(username|password)=(?[^&=]+?)(?= |&|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new Regex(@"torrentleech\.org/(?[0-9a-z]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new Regex(@"iptorrents\.com/[/a-z0-9?&;]*?(?:[?&;](u|tp)=(?[^&=;]+?))+(?= |;|&|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), + + // Path + new Regex(@"""C:\\Users\\(?[^\""]+?)(\\|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new Regex(@"""/home/(?[^/""]+?)(/|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), + // NzbGet new Regex(@"""Name""\s*:\s*""[^""]*(username|password)""\s*,\s*""Value""\s*:\s*""(?[^""]+?)""", RegexOptions.Compiled | RegexOptions.IgnoreCase), // Sabnzbd new Regex(@"""[^""]*(username|password|api_?key|nzb_key)""\s*:\s*""(?[^""]+?)""", RegexOptions.Compiled | RegexOptions.IgnoreCase), - new Regex(@"""email_(account|to|from|pwd)""\s*:\s*""(?[^""]+?)""", RegexOptions.Compiled | RegexOptions.IgnoreCase) - }; + new Regex(@"""email_(account|to|from|pwd)""\s*:\s*""(?[^""]+?)""", RegexOptions.Compiled | RegexOptions.IgnoreCase), - //private static readonly Regex CleansingRegex = new Regex(@"(?<=apikey=)(\w+?)(?=\W|$|_)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + // uTorrent + new Regex(@"\[""[a-z._]*(|username|password)"",\d,""(?[^""]+?)""", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new Regex(@"\[""(boss_key|boss_key_salt|proxy\.proxy)"",\d,""(?[^""]+?)""", RegexOptions.Compiled | RegexOptions.IgnoreCase), + + // BroadcastheNet + new Regex(@"""?method""?\s*:\s*""(getTorrents)"",\s*""?params""?\s*:\s*\[\s*""(?[^""]+?)""", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new Regex(@"(?<=\?|&)(authkey|torrent_pass)=(?[^&=]+?)(?=""|&|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase) + }; public static string Cleanse(string message) { @@ -29,7 +42,16 @@ namespace NzbDrone.Common.Instrumentation foreach (var regex in CleansingRules) { - message = regex.Replace(message, m => m.Value.Replace(m.Groups["secret"].Index - m.Index, m.Groups["secret"].Length, "")); + message = regex.Replace(message, m => + { + var value = m.Value; + foreach (var capture in m.Groups["secret"].Captures.OfType().Reverse()) + { + value = value.Replace(capture.Index - m.Index, capture.Length, ""); + } + + return value; + }); } return message; diff --git a/src/NzbDrone.Common/NzbDrone.Common.csproj b/src/NzbDrone.Common/NzbDrone.Common.csproj index fc8d9470e..1933d084c 100644 --- a/src/NzbDrone.Common/NzbDrone.Common.csproj +++ b/src/NzbDrone.Common/NzbDrone.Common.csproj @@ -71,8 +71,10 @@ + + @@ -139,6 +141,8 @@ + + Component diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs index 6972f9950..08c66f2ef 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Linq; using NzbDrone.Core.Tv; -using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Profiles; using NzbDrone.Core.Qualities; using NzbDrone.Core.Parser.Model; diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/RetentionSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/RetentionSpecificationFixture.cs index de2690771..e5249c4aa 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/RetentionSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/RetentionSpecificationFixture.cs @@ -3,6 +3,7 @@ using FluentAssertions; using NUnit.Framework; using NzbDrone.Core.Configuration; using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Indexers; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Test.Framework; @@ -13,14 +14,14 @@ namespace NzbDrone.Core.Test.DecisionEngineTests public class RetentionSpecificationFixture : CoreTest { - private RemoteEpisode parseResult; + private RemoteEpisode _remoteEpisode; [SetUp] public void Setup() { - parseResult = new RemoteEpisode + _remoteEpisode = new RemoteEpisode { - Release = new ReleaseInfo() + Release = new ReleaseInfo() { DownloadProtocol = DownloadProtocol.Usenet } }; } @@ -31,7 +32,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests private void WithAge(int days) { - parseResult.Release.PublishDate = DateTime.Now.AddDays(-days); + _remoteEpisode.Release.PublishDate = DateTime.Now.AddDays(-days); } [Test] @@ -40,7 +41,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests WithRetention(0); WithAge(100); - Subject.IsSatisfiedBy(parseResult, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); } [Test] @@ -49,7 +50,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests WithRetention(1000); WithAge(100); - Subject.IsSatisfiedBy(parseResult, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); } [Test] @@ -58,7 +59,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests WithRetention(100); WithAge(100); - Subject.IsSatisfiedBy(parseResult, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); } [Test] @@ -67,7 +68,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests WithRetention(10); WithAge(100); - Subject.IsSatisfiedBy(parseResult, null).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); } [Test] @@ -76,7 +77,18 @@ namespace NzbDrone.Core.Test.DecisionEngineTests WithRetention(0); WithAge(100); - Subject.IsSatisfiedBy(parseResult, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_true_when_release_is_not_usenet() + { + _remoteEpisode.Release.DownloadProtocol = DownloadProtocol.Torrent; + + WithRetention(10); + WithAge(100); + + Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); } } } \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceFixture.cs b/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceFixture.cs index 51f81076e..116ac2874 100644 --- a/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceFixture.cs @@ -31,7 +31,7 @@ namespace NzbDrone.Core.Test.Download _completed = Builder.CreateListOfSize(1) .All() .With(h => h.Status = DownloadItemStatus.Completed) - .With(h => h.OutputPath = @"C:\DropFolder\MyDownload".AsOsAgnostic()) + .With(h => h.OutputPath = new OsPath(@"C:\DropFolder\MyDownload".AsOsAgnostic())) .With(h => h.Title = "Drone.S01E01.HDTV") .Build() .ToList(); @@ -325,7 +325,7 @@ namespace NzbDrone.Core.Test.Download _completed.AddRange(Builder.CreateListOfSize(2) .All() .With(h => h.Status = DownloadItemStatus.Completed) - .With(h => h.OutputPath = @"C:\DropFolder\MyDownload".AsOsAgnostic()) + .With(h => h.OutputPath = new OsPath(@"C:\DropFolder\MyDownload".AsOsAgnostic())) .With(h => h.Title = "Drone.S01E01.HDTV") .Build()); diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/Blackhole/TorrentBlackholeFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/Blackhole/TorrentBlackholeFixture.cs new file mode 100644 index 000000000..5dbd60735 --- /dev/null +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/Blackhole/TorrentBlackholeFixture.cs @@ -0,0 +1,135 @@ +using System.IO; +using System.Net; +using System.Linq; +using Moq; +using NUnit.Framework; +using FluentAssertions; +using NzbDrone.Test.Common; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Common; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Http; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Download; +using NzbDrone.Core.Download.Clients; +using NzbDrone.Core.Download.Clients.TorrentBlackhole; +using System; + +namespace NzbDrone.Core.Test.Download.DownloadClientTests.Blackhole +{ + [TestFixture] + public class TorrentBlackholeFixture : DownloadClientFixtureBase + { + protected String _completedDownloadFolder; + protected String _blackholeFolder; + protected String _filePath; + + [SetUp] + public void Setup() + { + _completedDownloadFolder = @"c:\blackhole\completed".AsOsAgnostic(); + _blackholeFolder = @"c:\blackhole\torrent".AsOsAgnostic(); + _filePath = (@"c:\blackhole\torrent\" + _title + ".torrent").AsOsAgnostic(); + + Subject.Definition = new DownloadClientDefinition(); + Subject.Definition.Settings = new TorrentBlackholeSettings + { + TorrentFolder = _blackholeFolder, + WatchFolder = _completedDownloadFolder + }; + } + + protected void GivenFailedDownload() + { + Mocker.GetMock() + .Setup(s => s.Get(It.IsAny())) + .Throws(new WebException()); + } + + protected void GivenCompletedItem() + { + var targetDir = Path.Combine(_completedDownloadFolder, _title); + Mocker.GetMock() + .Setup(c => c.GetDirectories(_completedDownloadFolder)) + .Returns(new[] { targetDir }); + + Mocker.GetMock() + .Setup(c => c.GetFiles(targetDir, SearchOption.AllDirectories)) + .Returns(new[] { Path.Combine(_completedDownloadFolder, "somefile.mkv") }); + + Mocker.GetMock() + .Setup(c => c.GetFileSize(It.IsAny())) + .Returns(1000000); + } + + [Test] + public void completed_download_should_have_required_properties() + { + GivenCompletedItem(); + + var result = Subject.GetItems().Single(); + + VerifyCompleted(result); + } + + [Test] + public void should_return_category() + { + GivenCompletedItem(); + + var result = Subject.GetItems().Single(); + + // We must have a category or CDH won't pick it up. + result.Category.Should().NotBeNullOrWhiteSpace(); + } + + [Test] + public void Download_should_download_file_if_it_doesnt_exist() + { + var remoteEpisode = CreateRemoteEpisode(); + + Subject.Download(remoteEpisode); + + Mocker.GetMock().Verify(c => c.DownloadFile(_downloadUrl, _filePath), Times.Once()); + } + + [Test] + public void Download_should_replace_illegal_characters_in_title() + { + var illegalTitle = "Saturday Night Live - S38E08 - Jeremy Renner/Maroon 5 [SDTV]"; + var expectedFilename = Path.Combine(_blackholeFolder, "Saturday Night Live - S38E08 - Jeremy Renner+Maroon 5 [SDTV]" + Path.GetExtension(_filePath)); + + var remoteEpisode = CreateRemoteEpisode(); + remoteEpisode.Release.Title = illegalTitle; + + Subject.Download(remoteEpisode); + + Mocker.GetMock().Verify(c => c.DownloadFile(It.IsAny(), expectedFilename), Times.Once()); + } + + [Test] + public void GetItems_should_considered_locked_files_queued() + { + GivenCompletedItem(); + + Mocker.GetMock() + .Setup(c => c.IsFileLocked(It.IsAny())) + .Returns(true); + + var items = Subject.GetItems().ToList(); + + items.Count.Should().Be(1); + items.First().Status.Should().Be(DownloadItemStatus.Downloading); + } + + [Test] + public void should_return_status_with_outputdirs() + { + var result = Subject.GetStatus(); + + result.IsLocalhost.Should().BeTrue(); + result.OutputRootFolders.Should().NotBeNull(); + result.OutputRootFolders.First().Should().Be(_completedDownloadFolder); + } + } +} diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/Blackhole/UsenetBlackholeFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/Blackhole/UsenetBlackholeFixture.cs index f1f224e0a..3526345ad 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/Blackhole/UsenetBlackholeFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/Blackhole/UsenetBlackholeFixture.cs @@ -46,6 +46,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.Blackhole protected void GivenCompletedItem() { var targetDir = Path.Combine(_completedDownloadFolder, _title); + Mocker.GetMock() .Setup(c => c.GetDirectories(_completedDownloadFolder)) .Returns(new[] { targetDir }); @@ -69,6 +70,17 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.Blackhole VerifyCompleted(result); } + [Test] + public void should_return_category() + { + GivenCompletedItem(); + + var result = Subject.GetItems().Single(); + + // We must have a category or CDH won't pick it up. + result.Category.Should().NotBeNullOrWhiteSpace(); + } + [Test] public void Download_should_download_file_if_it_doesnt_exist() { diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/DelugeTests/DelugeFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/DelugeTests/DelugeFixture.cs new file mode 100644 index 000000000..8e3f7e784 --- /dev/null +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/DelugeTests/DelugeFixture.cs @@ -0,0 +1,310 @@ +using System; +using System.IO; +using System.Linq; +using System.Collections.Generic; +using FizzWare.NBuilder; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Common; +using NzbDrone.Common.Http; +using NzbDrone.Core.MediaFiles.TorrentInfo; +using NzbDrone.Core.Tv; +using NzbDrone.Core.Download; +using NzbDrone.Core.Download.Clients.Deluge; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Test.Common; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.Download.DownloadClientTests.DelugeTests +{ + [TestFixture] + public class DelugeFixture : DownloadClientFixtureBase + { + protected DelugeTorrent _queued; + protected DelugeTorrent _downloading; + protected DelugeTorrent _failed; + protected DelugeTorrent _completed; + + [SetUp] + public void Setup() + { + Subject.Definition = new DownloadClientDefinition(); + Subject.Definition.Settings = new DelugeSettings() + { + TvCategory = null + }; + + _queued = new DelugeTorrent + { + Hash = "HASH", + IsFinished = false, + State = DelugeTorrentStatus.Queued, + Name = _title, + Size = 1000, + BytesDownloaded = 0, + Progress = 0.0, + DownloadPath = "somepath" + }; + + _downloading = new DelugeTorrent + { + Hash = "HASH", + IsFinished = false, + State = DelugeTorrentStatus.Downloading, + Name = _title, + Size = 1000, + BytesDownloaded = 100, + Progress = 10.0, + DownloadPath = "somepath" + }; + + _failed = new DelugeTorrent + { + Hash = "HASH", + IsFinished = false, + State = DelugeTorrentStatus.Error, + Name = _title, + Size = 1000, + BytesDownloaded = 100, + Progress = 10.0, + Message = "Error", + DownloadPath = "somepath" + }; + + _completed = new DelugeTorrent + { + Hash = "HASH", + IsFinished = true, + State = DelugeTorrentStatus.Paused, + Name = _title, + Size = 1000, + BytesDownloaded = 1000, + Progress = 100.0, + DownloadPath = "somepath" + }; + + Mocker.GetMock() + .Setup(s => s.GetHashFromTorrentFile(It.IsAny())) + .Returns("CBC2F069FE8BB2F544EAE707D75BCD3DE9DCF951"); + + Mocker.GetMock() + .Setup(s => s.Get(It.IsAny())) + .Returns(r => new HttpResponse(r, new HttpHeader(), new Byte[0])); + } + + protected void GivenFailedDownload() + { + Mocker.GetMock() + .Setup(s => s.AddTorrentFromMagnet(It.IsAny(), It.IsAny())) + .Throws(); + + Mocker.GetMock() + .Setup(s => s.AddTorrentFromFile(It.IsAny(), It.IsAny(), It.IsAny())) + .Throws(); + } + + protected void GivenSuccessfulDownload() + { + Mocker.GetMock() + .Setup(s => s.Get(It.IsAny())) + .Returns(r => new HttpResponse(r, new HttpHeader(), new Byte[1000])); + + Mocker.GetMock() + .Setup(s => s.AddTorrentFromMagnet(It.IsAny(), It.IsAny())) + .Returns("CBC2F069FE8BB2F544EAE707D75BCD3DE9DCF951".ToLower()) + .Callback(PrepareClientToReturnQueuedItem); + + Mocker.GetMock() + .Setup(s => s.AddTorrentFromFile(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns("CBC2F069FE8BB2F544EAE707D75BCD3DE9DCF951".ToLower()) + .Callback(PrepareClientToReturnQueuedItem); + } + + protected virtual void GivenTorrents(List torrents) + { + if (torrents == null) + { + torrents = new List(); + } + + Mocker.GetMock() + .Setup(s => s.GetTorrents(It.IsAny())) + .Returns(torrents.ToArray()); + } + + protected void PrepareClientToReturnQueuedItem() + { + GivenTorrents(new List + { + _queued + }); + } + + protected void PrepareClientToReturnDownloadingItem() + { + GivenTorrents(new List + { + _downloading + }); + } + + protected void PrepareClientToReturnFailedItem() + { + GivenTorrents(new List + { + _failed + }); + } + + protected void PrepareClientToReturnCompletedItem() + { + GivenTorrents(new List + { + _completed + }); + } + + [Test] + public void queued_item_should_have_required_properties() + { + PrepareClientToReturnQueuedItem(); + var item = Subject.GetItems().Single(); + VerifyQueued(item); + } + + [Test] + public void downloading_item_should_have_required_properties() + { + PrepareClientToReturnDownloadingItem(); + var item = Subject.GetItems().Single(); + VerifyDownloading(item); + } + + [Test] + public void failed_item_should_have_required_properties() + { + PrepareClientToReturnFailedItem(); + var item = Subject.GetItems().Single(); + VerifyFailed(item); + } + + [Test] + public void completed_download_should_have_required_properties() + { + PrepareClientToReturnCompletedItem(); + var item = Subject.GetItems().Single(); + VerifyCompleted(item); + } + + [Test] + public void Download_should_return_unique_id() + { + GivenSuccessfulDownload(); + + var remoteEpisode = CreateRemoteEpisode(); + + var id = Subject.Download(remoteEpisode); + + id.Should().NotBeNullOrEmpty(); + } + + [TestCase("magnet:?xt=urn:btih:ZPBPA2P6ROZPKRHK44D5OW6NHXU5Z6KR&tr=udp", "CBC2F069FE8BB2F544EAE707D75BCD3DE9DCF951")] + public void Download_should_get_hash_from_magnet_url(String magnetUrl, String expectedHash) + { + GivenSuccessfulDownload(); + + var remoteEpisode = CreateRemoteEpisode(); + remoteEpisode.Release.DownloadUrl = magnetUrl; + + var id = Subject.Download(remoteEpisode); + + id.Should().Be(expectedHash); + } + + [TestCase(DelugeTorrentStatus.Paused, DownloadItemStatus.Paused)] + [TestCase(DelugeTorrentStatus.Checking, DownloadItemStatus.Downloading)] + [TestCase(DelugeTorrentStatus.Queued, DownloadItemStatus.Queued)] + [TestCase(DelugeTorrentStatus.Downloading, DownloadItemStatus.Downloading)] + [TestCase(DelugeTorrentStatus.Seeding, DownloadItemStatus.Downloading)] + public void GetItems_should_return_queued_item_as_downloadItemStatus(String apiStatus, DownloadItemStatus expectedItemStatus) + { + _queued.State = apiStatus; + + PrepareClientToReturnQueuedItem(); + + var item = Subject.GetItems().Single(); + + item.Status.Should().Be(expectedItemStatus); + } + + [TestCase(DelugeTorrentStatus.Paused, DownloadItemStatus.Paused)] + [TestCase(DelugeTorrentStatus.Checking, DownloadItemStatus.Downloading)] + [TestCase(DelugeTorrentStatus.Queued, DownloadItemStatus.Queued)] + [TestCase(DelugeTorrentStatus.Downloading, DownloadItemStatus.Downloading)] + [TestCase(DelugeTorrentStatus.Seeding, DownloadItemStatus.Downloading)] + public void GetItems_should_return_downloading_item_as_downloadItemStatus(String apiStatus, DownloadItemStatus expectedItemStatus) + { + _downloading.State = apiStatus; + + PrepareClientToReturnDownloadingItem(); + + var item = Subject.GetItems().Single(); + + item.Status.Should().Be(expectedItemStatus); + } + + [TestCase(DelugeTorrentStatus.Paused, DownloadItemStatus.Completed, true)] + [TestCase(DelugeTorrentStatus.Checking, DownloadItemStatus.Downloading, true)] + [TestCase(DelugeTorrentStatus.Queued, DownloadItemStatus.Completed, true)] + [TestCase(DelugeTorrentStatus.Seeding, DownloadItemStatus.Completed, true)] + public void GetItems_should_return_completed_item_as_downloadItemStatus(String apiStatus, DownloadItemStatus expectedItemStatus, Boolean expectedReadOnly) + { + _completed.State = apiStatus; + + PrepareClientToReturnCompletedItem(); + + var item = Subject.GetItems().Single(); + + item.Status.Should().Be(expectedItemStatus); + item.IsReadOnly.Should().Be(expectedReadOnly); + } + + [Test] + public void GetItems_should_check_share_ratio_for_readonly() + { + _completed.State = DelugeTorrentStatus.Paused; + _completed.IsAutoManaged = true; + _completed.StopAtRatio = true; + _completed.StopRatio = 1.0; + _completed.Ratio = 1.01; + + PrepareClientToReturnCompletedItem(); + + var item = Subject.GetItems().Single(); + + item.Status.Should().Be(DownloadItemStatus.Completed); + item.IsReadOnly.Should().BeFalse(); + } + + [Test] + public void should_return_status_with_outputdirs() + { + var configItems = new Dictionary(); + + configItems.Add("download_location", @"C:\Downloads\Downloading\deluge".AsOsAgnostic()); + configItems.Add("move_completed_path", @"C:\Downloads\Finished\deluge".AsOsAgnostic()); + configItems.Add("move_completed", true); + + Mocker.GetMock() + .Setup(v => v.GetConfig(It.IsAny())) + .Returns(configItems); + + var result = Subject.GetStatus(); + + result.IsLocalhost.Should().BeTrue(); + result.OutputRootFolders.Should().NotBeNull(); + result.OutputRootFolders.First().Should().Be(@"C:\Downloads\Finished\deluge".AsOsAgnostic()); + } + } +} diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadClientFixtureBase.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadClientFixtureBase.cs index 9186072a1..0a7272e1b 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadClientFixtureBase.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadClientFixtureBase.cs @@ -39,8 +39,8 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests .Returns(r => new HttpResponse(r, new HttpHeader(), new Byte[0])); Mocker.GetMock() - .Setup(v => v.RemapRemoteToLocal(It.IsAny(), It.IsAny())) - .Returns((h,r) => r); + .Setup(v => v.RemapRemoteToLocal(It.IsAny(), It.IsAny())) + .Returns((h, r) => r); } protected virtual RemoteEpisode CreateRemoteEpisode() diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbgetTests/NzbgetFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbgetTests/NzbgetFixture.cs index 87d193506..a6b0e1cfc 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbgetTests/NzbgetFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbgetTests/NzbgetFixture.cs @@ -14,6 +14,7 @@ using NzbDrone.Core.Parser.Model; using NzbDrone.Test.Common; using NzbDrone.Core.Test.Framework; using NzbDrone.Core.RemotePathMappings; +using NzbDrone.Common.Disk; namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetTests { @@ -300,8 +301,8 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetTests public void should_return_status_with_mounted_outputdir() { Mocker.GetMock() - .Setup(v => v.RemapRemoteToLocal("127.0.0.1", "/remote/mount/tv")) - .Returns(@"O:\mymount".AsOsAgnostic()); + .Setup(v => v.RemapRemoteToLocal("127.0.0.1", It.IsAny())) + .Returns(new OsPath(@"O:\mymount".AsOsAgnostic())); var result = Subject.GetStatus(); @@ -314,8 +315,8 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetTests public void should_remap_storage_if_mounted() { Mocker.GetMock() - .Setup(v => v.RemapRemoteToLocal("127.0.0.1", "/remote/mount/tv/Droned.S01E01.Pilot.1080p.WEB-DL-DRONE")) - .Returns(@"O:\mymount\Droned.S01E01.Pilot.1080p.WEB-DL-DRONE".AsOsAgnostic()); + .Setup(v => v.RemapRemoteToLocal("127.0.0.1", It.IsAny())) + .Returns(new OsPath(@"O:\mymount\Droned.S01E01.Pilot.1080p.WEB-DL-DRONE".AsOsAgnostic())); GivenQueue(null); GivenHistory(_completed); diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/SabnzbdTests/SabnzbdFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/SabnzbdTests/SabnzbdFixture.cs index 7a5549bbf..686d6b75d 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/SabnzbdTests/SabnzbdFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/SabnzbdTests/SabnzbdFixture.cs @@ -11,6 +11,7 @@ using NzbDrone.Core.Download.Clients.Sabnzbd.Responses; using NzbDrone.Core.Tv; using NzbDrone.Test.Common; using NzbDrone.Core.RemotePathMappings; +using NzbDrone.Common.Disk; namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests { @@ -303,15 +304,15 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests var result = Subject.GetItems().Single(); - result.OutputPath.Should().Be((@"C:\sorted\" + title).AsOsAgnostic()); + result.OutputPath.Should().Be(new OsPath((@"C:\sorted\" + title).AsOsAgnostic()).AsDirectory()); } [Test] public void should_remap_storage_if_mounted() { Mocker.GetMock() - .Setup(v => v.RemapRemoteToLocal("127.0.0.1", "/remote/mount/vv/Droned.S01E01.Pilot.1080p.WEB-DL-DRONE")) - .Returns(@"O:\mymount\Droned.S01E01.Pilot.1080p.WEB-DL-DRONE".AsOsAgnostic()); + .Setup(v => v.RemapRemoteToLocal("127.0.0.1", It.IsAny())) + .Returns(new OsPath(@"O:\mymount\Droned.S01E01.Pilot.1080p.WEB-DL-DRONE".AsOsAgnostic())); GivenQueue(null); GivenHistory(_completed); @@ -370,8 +371,8 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests public void should_return_status_with_mounted_outputdir() { Mocker.GetMock() - .Setup(v => v.RemapRemoteToLocal("127.0.0.1", "/remote/mount/vv")) - .Returns(@"O:\mymount".AsOsAgnostic()); + .Setup(v => v.RemapRemoteToLocal("127.0.0.1", It.IsAny())) + .Returns(new OsPath(@"O:\mymount".AsOsAgnostic())); GivenQueue(null); diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/TransmissionTests/TransmissionFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/TransmissionTests/TransmissionFixture.cs new file mode 100644 index 000000000..0556528cf --- /dev/null +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/TransmissionTests/TransmissionFixture.cs @@ -0,0 +1,363 @@ +using System; +using System.IO; +using System.Linq; +using System.Collections.Generic; +using FizzWare.NBuilder; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Common; +using NzbDrone.Common.Http; +using NzbDrone.Core.MediaFiles.TorrentInfo; +using NzbDrone.Core.Tv; +using NzbDrone.Core.Download; +using NzbDrone.Core.Download.Clients.Transmission; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Test.Common; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.Download.DownloadClientTests.TransmissionTests +{ + [TestFixture] + public class TransmissionFixture : DownloadClientFixtureBase + { + protected TransmissionSettings _settings; + protected TransmissionTorrent _queued; + protected TransmissionTorrent _downloading; + protected TransmissionTorrent _failed; + protected TransmissionTorrent _completed; + + [SetUp] + public void Setup() + { + _settings = new TransmissionSettings + { + Host = "127.0.0.1", + Port = 2222, + Username = "admin", + Password = "pass" + }; + + Subject.Definition = new DownloadClientDefinition(); + Subject.Definition.Settings = _settings; + + _queued = new TransmissionTorrent + { + HashString = "HASH", + IsFinished = false, + Status = TransmissionTorrentStatus.Queued, + Name = _title, + TotalSize = 1000, + LeftUntilDone = 1000, + DownloadDir = "somepath" + }; + + _downloading = new TransmissionTorrent + { + HashString = "HASH", + IsFinished = false, + Status = TransmissionTorrentStatus.Downloading, + Name = _title, + TotalSize = 1000, + LeftUntilDone = 100, + DownloadDir = "somepath" + }; + + _failed = new TransmissionTorrent + { + HashString = "HASH", + IsFinished = false, + Status = TransmissionTorrentStatus.Stopped, + Name = _title, + TotalSize = 1000, + LeftUntilDone = 100, + ErrorString = "Error", + DownloadDir = "somepath" + }; + + _completed = new TransmissionTorrent + { + HashString = "HASH", + IsFinished = true, + Status = TransmissionTorrentStatus.Stopped, + Name = _title, + TotalSize = 1000, + LeftUntilDone = 0, + DownloadDir = "somepath" + }; + + Mocker.GetMock() + .Setup(s => s.GetHashFromTorrentFile(It.IsAny())) + .Returns("CBC2F069FE8BB2F544EAE707D75BCD3DE9DCF951"); + + Mocker.GetMock() + .Setup(s => s.Get(It.IsAny())) + .Returns(r => new HttpResponse(r, new HttpHeader(), new Byte[0])); + + var configItems = new Dictionary(); + + configItems.Add("download-dir", @"C:/Downloads/Finished/transmission"); + configItems.Add("incomplete-dir", null); + configItems.Add("incomplete-dir-enabled", false); + + Mocker.GetMock() + .Setup(v => v.GetConfig(It.IsAny())) + .Returns(configItems); + + } + + protected void GivenTvCategory() + { + _settings.TvCategory = "nzbdrone"; + } + + protected void GivenFailedDownload() + { + Mocker.GetMock() + .Setup(s => s.AddTorrentFromUrl(It.IsAny(), It.IsAny(), It.IsAny())) + .Throws(); + } + + protected void GivenSuccessfulDownload() + { + Mocker.GetMock() + .Setup(s => s.Get(It.IsAny())) + .Returns(r => new HttpResponse(r, new HttpHeader(), new Byte[1000])); + + Mocker.GetMock() + .Setup(s => s.AddTorrentFromUrl(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback(PrepareClientToReturnQueuedItem); + + Mocker.GetMock() + .Setup(s => s.AddTorrentFromData(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback(PrepareClientToReturnQueuedItem); + } + + protected virtual void GivenTorrents(List torrents) + { + if (torrents == null) + { + torrents = new List(); + } + + Mocker.GetMock() + .Setup(s => s.GetTorrents(It.IsAny())) + .Returns(torrents); + } + + protected void PrepareClientToReturnQueuedItem() + { + GivenTorrents(new List + { + _queued + }); + } + + protected void PrepareClientToReturnDownloadingItem() + { + GivenTorrents(new List + { + _downloading + }); + } + + protected void PrepareClientToReturnFailedItem() + { + GivenTorrents(new List + { + _failed + }); + } + + protected void PrepareClientToReturnCompletedItem() + { + GivenTorrents(new List + { + _completed + }); + } + + [Test] + public void queued_item_should_have_required_properties() + { + PrepareClientToReturnQueuedItem(); + var item = Subject.GetItems().Single(); + VerifyQueued(item); + } + + [Test] + public void downloading_item_should_have_required_properties() + { + PrepareClientToReturnDownloadingItem(); + var item = Subject.GetItems().Single(); + VerifyDownloading(item); + } + + [Test] + public void failed_item_should_have_required_properties() + { + PrepareClientToReturnFailedItem(); + var item = Subject.GetItems().Single(); + VerifyFailed(item); + } + + [Test] + public void completed_download_should_have_required_properties() + { + PrepareClientToReturnCompletedItem(); + var item = Subject.GetItems().Single(); + VerifyCompleted(item); + } + + [Test] + public void Download_should_return_unique_id() + { + GivenSuccessfulDownload(); + + var remoteEpisode = CreateRemoteEpisode(); + + var id = Subject.Download(remoteEpisode); + + id.Should().NotBeNullOrEmpty(); + } + + [Test] + public void Download_with_category_should_force_directory() + { + GivenTvCategory(); + GivenSuccessfulDownload(); + + var remoteEpisode = CreateRemoteEpisode(); + + var id = Subject.Download(remoteEpisode); + + id.Should().NotBeNullOrEmpty(); + + Mocker.GetMock() + .Verify(v => v.AddTorrentFromData(It.IsAny(), @"C:/Downloads/Finished/transmission/.nzbdrone", It.IsAny()), Times.Once()); + } + + [TestCase("magnet:?xt=urn:btih:ZPBPA2P6ROZPKRHK44D5OW6NHXU5Z6KR&tr=udp", "CBC2F069FE8BB2F544EAE707D75BCD3DE9DCF951")] + public void Download_should_get_hash_from_magnet_url(String magnetUrl, String expectedHash) + { + GivenSuccessfulDownload(); + + var remoteEpisode = CreateRemoteEpisode(); + remoteEpisode.Release.DownloadUrl = magnetUrl; + + var id = Subject.Download(remoteEpisode); + + id.Should().Be(expectedHash); + } + + [TestCase(TransmissionTorrentStatus.Stopped, DownloadItemStatus.Downloading)] + [TestCase(TransmissionTorrentStatus.CheckWait, DownloadItemStatus.Downloading)] + [TestCase(TransmissionTorrentStatus.Check, DownloadItemStatus.Downloading)] + [TestCase(TransmissionTorrentStatus.Queued, DownloadItemStatus.Queued)] + [TestCase(TransmissionTorrentStatus.Downloading, DownloadItemStatus.Downloading)] + [TestCase(TransmissionTorrentStatus.SeedingWait, DownloadItemStatus.Completed)] + [TestCase(TransmissionTorrentStatus.Seeding, DownloadItemStatus.Completed)] + public void GetItems_should_return_queued_item_as_downloadItemStatus(TransmissionTorrentStatus apiStatus, DownloadItemStatus expectedItemStatus) + { + _queued.Status = apiStatus; + + PrepareClientToReturnQueuedItem(); + + var item = Subject.GetItems().Single(); + + item.Status.Should().Be(expectedItemStatus); + } + + [TestCase(TransmissionTorrentStatus.Queued, DownloadItemStatus.Queued)] + [TestCase(TransmissionTorrentStatus.Downloading, DownloadItemStatus.Downloading)] + [TestCase(TransmissionTorrentStatus.Seeding, DownloadItemStatus.Completed)] + public void GetItems_should_return_downloading_item_as_downloadItemStatus(TransmissionTorrentStatus apiStatus, DownloadItemStatus expectedItemStatus) + { + _downloading.Status = apiStatus; + + PrepareClientToReturnDownloadingItem(); + + var item = Subject.GetItems().Single(); + + item.Status.Should().Be(expectedItemStatus); + } + + [TestCase(TransmissionTorrentStatus.Stopped, DownloadItemStatus.Completed, false)] + [TestCase(TransmissionTorrentStatus.CheckWait, DownloadItemStatus.Downloading, true)] + [TestCase(TransmissionTorrentStatus.Check, DownloadItemStatus.Downloading, true)] + [TestCase(TransmissionTorrentStatus.Queued, DownloadItemStatus.Completed, true)] + [TestCase(TransmissionTorrentStatus.SeedingWait, DownloadItemStatus.Completed, true)] + [TestCase(TransmissionTorrentStatus.Seeding, DownloadItemStatus.Completed, true)] + public void GetItems_should_return_completed_item_as_downloadItemStatus(TransmissionTorrentStatus apiStatus, DownloadItemStatus expectedItemStatus, Boolean expectedReadOnly) + { + _completed.Status = apiStatus; + + PrepareClientToReturnCompletedItem(); + + var item = Subject.GetItems().Single(); + + item.Status.Should().Be(expectedItemStatus); + item.IsReadOnly.Should().Be(expectedReadOnly); + } + + [Test] + public void should_return_status_with_outputdirs() + { + var result = Subject.GetStatus(); + + result.IsLocalhost.Should().BeTrue(); + result.OutputRootFolders.Should().NotBeNull(); + result.OutputRootFolders.First().Should().Be(@"C:\Downloads\Finished\transmission"); + } + + [Test] + public void should_exclude_items_not_in_category() + { + GivenTvCategory(); + + _downloading.DownloadDir = @"C:/Downloads/Finished/transmission/.nzbdrone"; + + GivenTorrents(new List + { + _downloading, + _queued + }); + + var items = Subject.GetItems().ToList(); + + items.Count.Should().Be(1); + items.First().Status.Should().Be(DownloadItemStatus.Downloading); + } + + [Test] + public void should_fix_forward_slashes() + { + WindowsOnly(); + + _downloading.DownloadDir = @"C:/Downloads/Finished/transmission"; + + GivenTorrents(new List + { + _downloading + }); + + var items = Subject.GetItems().ToList(); + + items.Should().HaveCount(1); + items.First().OutputPath.Should().Be(@"C:\Downloads\Finished\transmission\" + _title); + } + + [TestCase("2.84 ()")] + [TestCase("2.84+ ()")] + [TestCase("2.84 (other info)")] + [TestCase("2.84 (2.84)")] + public void should_version_should_only_check_version_number(String version) + { + Mocker.GetMock() + .Setup(s => s.GetVersion(It.IsAny())) + .Returns(version); + + Subject.Test(); + } + } +} diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/UTorrentTests/UTorrentFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/UTorrentTests/UTorrentFixture.cs new file mode 100644 index 000000000..4d2635c23 --- /dev/null +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/UTorrentTests/UTorrentFixture.cs @@ -0,0 +1,340 @@ +using System; +using System.IO; +using System.Linq; +using System.Collections.Generic; +using FizzWare.NBuilder; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Common; +using NzbDrone.Common.Http; +using NzbDrone.Core.MediaFiles.TorrentInfo; +using NzbDrone.Core.Tv; +using NzbDrone.Core.Download; +using NzbDrone.Core.Download.Clients.UTorrent; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Test.Common; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.Download.DownloadClientTests.UTorrentTests +{ + [TestFixture] + public class UTorrentFixture : DownloadClientFixtureBase + { + protected UTorrentTorrent _queued; + protected UTorrentTorrent _downloading; + protected UTorrentTorrent _failed; + protected UTorrentTorrent _completed; + + [SetUp] + public void Setup() + { + Subject.Definition = new DownloadClientDefinition(); + Subject.Definition.Settings = new UTorrentSettings + { + Host = "127.0.0.1", + Port = 2222, + Username = "admin", + Password = "pass", + TvCategory = "tv" + }; + + _queued = new UTorrentTorrent + { + Hash = "HASH", + Status = UTorrentTorrentStatus.Queued | UTorrentTorrentStatus.Loaded, + Name = _title, + Size = 1000, + Remaining = 1000, + Progress = 0, + Label = "tv", + DownloadUrl = _downloadUrl, + RootDownloadPath = "somepath" + }; + + _downloading = new UTorrentTorrent + { + Hash = "HASH", + Status = UTorrentTorrentStatus.Started | UTorrentTorrentStatus.Loaded, + Name = _title, + Size = 1000, + Remaining = 100, + Progress = 0.9, + Label = "tv", + DownloadUrl = _downloadUrl, + RootDownloadPath = "somepath" + }; + + _failed = new UTorrentTorrent + { + Hash = "HASH", + Status = UTorrentTorrentStatus.Error, + Name = _title, + Size = 1000, + Remaining = 100, + Progress = 0.9, + Label = "tv", + DownloadUrl = _downloadUrl, + RootDownloadPath = "somepath" + }; + + _completed = new UTorrentTorrent + { + Hash = "HASH", + Status = UTorrentTorrentStatus.Checked | UTorrentTorrentStatus.Loaded, + Name = _title, + Size = 1000, + Remaining = 0, + Progress = 1.0, + Label = "tv", + DownloadUrl = _downloadUrl, + RootDownloadPath = "somepath" + }; + + Mocker.GetMock() + .Setup(s => s.GetHashFromTorrentFile(It.IsAny())) + .Returns("CBC2F069FE8BB2F544EAE707D75BCD3DE9DCF951"); + + Mocker.GetMock() + .Setup(s => s.Get(It.IsAny())) + .Returns(r => new HttpResponse(r, new HttpHeader(), new Byte[0])); + } + + protected void GivenRedirectToMagnet() + { + var httpHeader = new HttpHeader(); + httpHeader["Location"] = "magnet:?xt=urn:btih:ZPBPA2P6ROZPKRHK44D5OW6NHXU5Z6KR&tr=udp"; + + Mocker.GetMock() + .Setup(s => s.Get(It.IsAny())) + .Returns(r => new HttpResponse(r, httpHeader, new Byte[0], System.Net.HttpStatusCode.SeeOther)); + } + + protected void GivenFailedDownload() + { + Mocker.GetMock() + .Setup(s => s.AddTorrentFromUrl(It.IsAny(), It.IsAny())) + .Throws(); + } + + protected void GivenSuccessfulDownload() + { + Mocker.GetMock() + .Setup(s => s.AddTorrentFromUrl(It.IsAny(), It.IsAny())) + .Callback(() => + { + PrepareClientToReturnQueuedItem(); + }); + } + + protected virtual void GivenTorrents(List torrents) + { + if (torrents == null) + { + torrents = new List(); + } + + Mocker.GetMock() + .Setup(s => s.GetTorrents(It.IsAny())) + .Returns(torrents); + } + + protected void PrepareClientToReturnQueuedItem() + { + GivenTorrents(new List + { + _queued + }); + } + + protected void PrepareClientToReturnDownloadingItem() + { + GivenTorrents(new List + { + _downloading + }); + } + + protected void PrepareClientToReturnFailedItem() + { + GivenTorrents(new List + { + _failed + }); + } + + protected void PrepareClientToReturnCompletedItem() + { + GivenTorrents(new List + { + _completed + }); + } + + [Test] + public void queued_item_should_have_required_properties() + { + PrepareClientToReturnQueuedItem(); + var item = Subject.GetItems().Single(); + VerifyQueued(item); + } + + [Test] + public void downloading_item_should_have_required_properties() + { + PrepareClientToReturnDownloadingItem(); + var item = Subject.GetItems().Single(); + VerifyDownloading(item); + } + + [Test] + public void failed_item_should_have_required_properties() + { + PrepareClientToReturnFailedItem(); + var item = Subject.GetItems().Single(); + VerifyFailed(item); + } + + [Test] + public void completed_download_should_have_required_properties() + { + PrepareClientToReturnCompletedItem(); + var item = Subject.GetItems().Single(); + VerifyCompleted(item); + } + + [Test] + public void Download_should_return_unique_id() + { + GivenSuccessfulDownload(); + + var remoteEpisode = CreateRemoteEpisode(); + + var id = Subject.Download(remoteEpisode); + + id.Should().NotBeNullOrEmpty(); + } + + [Test] + public void GetItems_should_ignore_downloads_from_other_categories() + { + _completed.Label = "myowncat"; + PrepareClientToReturnCompletedItem(); + + var items = Subject.GetItems(); + + items.Should().BeEmpty(); + } + + // Proxy.GetTorrents does not return original url. So item has to be found via magnet url. + [TestCase("magnet:?xt=urn:btih:ZPBPA2P6ROZPKRHK44D5OW6NHXU5Z6KR&tr=udp", "CBC2F069FE8BB2F544EAE707D75BCD3DE9DCF951")] + public void Download_should_get_hash_from_magnet_url(String magnetUrl, String expectedHash) + { + GivenSuccessfulDownload(); + + var remoteEpisode = CreateRemoteEpisode(); + remoteEpisode.Release.DownloadUrl = magnetUrl; + + var id = Subject.Download(remoteEpisode); + + id.Should().Be(expectedHash); + } + + [TestCase(UTorrentTorrentStatus.Loaded, DownloadItemStatus.Queued)] + [TestCase(UTorrentTorrentStatus.Loaded | UTorrentTorrentStatus.Checking, DownloadItemStatus.Queued)] + [TestCase(UTorrentTorrentStatus.Loaded | UTorrentTorrentStatus.Queued, DownloadItemStatus.Queued)] + [TestCase(UTorrentTorrentStatus.Loaded | UTorrentTorrentStatus.Started, DownloadItemStatus.Downloading)] + [TestCase(UTorrentTorrentStatus.Loaded | UTorrentTorrentStatus.Queued | UTorrentTorrentStatus.Started, DownloadItemStatus.Downloading)] + public void GetItems_should_return_queued_item_as_downloadItemStatus(UTorrentTorrentStatus apiStatus, DownloadItemStatus expectedItemStatus) + { + _queued.Status = apiStatus; + + PrepareClientToReturnQueuedItem(); + + var item = Subject.GetItems().Single(); + + item.Status.Should().Be(expectedItemStatus); + } + + [TestCase(UTorrentTorrentStatus.Loaded | UTorrentTorrentStatus.Checking, DownloadItemStatus.Queued)] + [TestCase(UTorrentTorrentStatus.Loaded | UTorrentTorrentStatus.Checked | UTorrentTorrentStatus.Queued, DownloadItemStatus.Queued)] + [TestCase(UTorrentTorrentStatus.Loaded | UTorrentTorrentStatus.Started, DownloadItemStatus.Downloading)] + [TestCase(UTorrentTorrentStatus.Loaded | UTorrentTorrentStatus.Queued | UTorrentTorrentStatus.Started, DownloadItemStatus.Downloading)] + public void GetItems_should_return_downloading_item_as_downloadItemStatus(UTorrentTorrentStatus apiStatus, DownloadItemStatus expectedItemStatus) + { + _downloading.Status = apiStatus; + + PrepareClientToReturnDownloadingItem(); + + var item = Subject.GetItems().Single(); + + item.Status.Should().Be(expectedItemStatus); + } + + [TestCase(UTorrentTorrentStatus.Loaded | UTorrentTorrentStatus.Checking, DownloadItemStatus.Queued, false)] + [TestCase(UTorrentTorrentStatus.Loaded | UTorrentTorrentStatus.Checked, DownloadItemStatus.Completed, false)] + [TestCase(UTorrentTorrentStatus.Loaded | UTorrentTorrentStatus.Checked | UTorrentTorrentStatus.Queued, DownloadItemStatus.Completed, true)] + [TestCase(UTorrentTorrentStatus.Loaded | UTorrentTorrentStatus.Checked | UTorrentTorrentStatus.Started, DownloadItemStatus.Completed, true)] + [TestCase(UTorrentTorrentStatus.Loaded | UTorrentTorrentStatus.Checked | UTorrentTorrentStatus.Queued | UTorrentTorrentStatus.Paused, DownloadItemStatus.Completed, true)] + public void GetItems_should_return_completed_item_as_downloadItemStatus(UTorrentTorrentStatus apiStatus, DownloadItemStatus expectedItemStatus, Boolean expectedReadOnly) + { + _completed.Status = apiStatus; + + PrepareClientToReturnCompletedItem(); + + var item = Subject.GetItems().Single(); + + item.Status.Should().Be(expectedItemStatus); + item.IsReadOnly.Should().Be(expectedReadOnly); + } + + [Test] + public void should_return_status_with_outputdirs() + { + var configItems = new Dictionary(); + + configItems.Add("dir_active_download_flag", "true"); + configItems.Add("dir_active_download", @"C:\Downloads\Downloading\utorrent".AsOsAgnostic()); + configItems.Add("dir_completed_download", @"C:\Downloads\Finished\utorrent".AsOsAgnostic()); + configItems.Add("dir_completed_download_flag", "true"); + configItems.Add("dir_add_label", "true"); + + Mocker.GetMock() + .Setup(v => v.GetConfig(It.IsAny())) + .Returns(configItems); + + var result = Subject.GetStatus(); + + result.IsLocalhost.Should().BeTrue(); + result.OutputRootFolders.Should().NotBeNull(); + result.OutputRootFolders.First().Should().Be(@"C:\Downloads\Finished\utorrent\tv".AsOsAgnostic()); + } + + [Test] + public void should_combine_drive_letter() + { + WindowsOnly(); + + _completed.RootDownloadPath = "D:"; + + PrepareClientToReturnCompletedItem(); + + var item = Subject.GetItems().Single(); + + item.OutputPath.Should().Be(@"D:\" + _title); + } + + [Test] + public void Download_should_handle_http_redirect_to_magnet() + { + GivenRedirectToMagnet(); + GivenSuccessfulDownload(); + + var remoteEpisode = CreateRemoteEpisode(); + + var id = Subject.Download(remoteEpisode); + + id.Should().NotBeNullOrEmpty(); + } + } +} diff --git a/src/NzbDrone.Core.Test/Files/Indexers/BroadcastheNet/RecentFeed.json b/src/NzbDrone.Core.Test/Files/Indexers/BroadcastheNet/RecentFeed.json new file mode 100644 index 000000000..9ac55ee7c --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/Indexers/BroadcastheNet/RecentFeed.json @@ -0,0 +1,61 @@ +{ +"id":"9787693d", +"result":{ +"torrents":{ +"123":{ +"GroupName":"2014.09.15", +"GroupID":"237457", +"TorrentID":"123", +"SeriesID":"1034", +"Series":"Jimmy Kimmel Live", +"SeriesBanner":"https:\/\/cdn2.broadcasthe.net\/tvdb\/banners\/graphical\/71998-g.jpg", +"SeriesPoster":"https:\/\/cdn2.broadcasthe.net\/tvdb\/banners\/posters\/71998-3.jpg", +"YoutubeTrailer":"http:\/\/www.youtube.com\/v\/w3NwB9PLxss", +"Category":"Episode", +"Snatched":"40", +"Seeders":"40", +"Leechers":"9", +"Source":"HDTV", +"Container":"MP4", +"Codec":"x264", +"Resolution":"SD", +"Origin":"Scene", +"ReleaseName":"Jimmy.Kimmel.2014.09.15.Jane.Fonda.HDTV.x264-aAF", +"Size":"505099926", +"Time":"1410902133", +"TvdbID":"71998", +"TvrageID":"4055", +"ImdbID":"0320037", +"InfoHash":"123", +"DownloadURL":"https:\/\/broadcasthe.net\/torrents.php?action=download&id=123&authkey=123&torrent_pass=123" +}, +"1234":{ +"GroupName":"S01E02", +"GroupID":"237456", +"TorrentID":"1234", +"SeriesID":"45853", +"Series":"Mammon", +"SeriesBanner":"https:\/\/cdn2.broadcasthe.net\/tvdb\/banners\/text\/274366.jpg", +"SeriesPoster":"\/\/cdn2.broadcasthe.net\/tvdb\/banners\/posters\/274366-2.jpg", +"YoutubeTrailer":"http:\/\/www.youtube.com\/v\/1VVbJecvHr8", +"Category":"Episode", +"Snatched":"0", +"Seeders":"1", +"Leechers":"23", +"Source":"HDTV", +"Container":"TS", +"Codec":"h.264", +"Resolution":"1080i", +"Origin":"Internal", +"ReleaseName":"Mammon.S01E02.1080i.HDTV.H.264-Irishman", +"Size":"4021238596", +"Time":"1410901918", +"TvdbID":"274366", +"TvrageID":"38472", +"ImdbID":"2377081", +"InfoHash":"1234", +"DownloadURL":"https:\/\/broadcasthe.net\/torrents.php?action=download&id=1234&authkey=1234&torrent_pass=1234" +}}, +"results":"117927" +} +} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/Files/RSS/BitMeTv.xml b/src/NzbDrone.Core.Test/Files/RSS/BitMeTv.xml new file mode 100644 index 000000000..345c51c87 --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/RSS/BitMeTv.xml @@ -0,0 +1,65 @@ + + + + 10 + BitMeTV.ORG + http://www.bitmetv.org + This is a private - by registration only - website. You can help keep it alive by donating: http://www.bitmetv.org/donate.php + en-usde + Copyright © 2004 - 2007 BitMeTV.ORG + noreply@bitmetv.org + + BitMeTV.ORG + http://www.bitmetv.org/favicon.ico + http://www.bitmetv.org + 16 + 16 + This is a private - by registration only - website. You can help keep it alive by donating: http://www.bitmetv.org/donate.php + + + Total.Divas.S02E08.HDTV.x264-CRiMSON + http://www.bitmetv.org/download.php/12/Total.Divas.S02E08.HDTV.x264-CRiMSON.torrent + Tue, 13 May 2014 17:04:29 -0000 + + Category: (Reality TV - Un-scripted) + Size: 376.71 MB + + + + Aqua.Teen.Hunger.Force.S10.INTERNAL.HDTV.x264-BitMeTV + http://www.bitmetv.org/download.php/34/Aqua.Teen.Hunger.Force.S10.INTERNAL.HDTV.x264-BitMeTV.torrent + Tue, 13 May 2014 17:03:12 -0000 + + Category: (Adult Swim) + Size: 725.46 MB + + + + Antiques.Roadshow.US.S18E16.720p.HDTV.x264-BAJSKORV + http://www.bitmetv.org/download.php/56/Antiques.Roadshow.US.S18E16.720p.HDTV.x264-BAJSKORV.torrent + Tue, 13 May 2014 16:47:05 -0000 + + Category: (Reality TV - Un-scripted) + Size: 960.15 MB + + + + Seth.Meyers.2014.05.12.Chris.O.Dowd-Emma.Roberts.HDTV.x264-CROOKS + http://www.bitmetv.org/download.php/78/Seth.Meyers.2014.05.12.Chris.O.Dowd-Emma.Roberts.HDTV.x264-CROOKS.torrent + Tue, 13 May 2014 16:01:21 -0000 + + Category: Seth Meyers + Size: 301.31 MB + + + + The.Mole.Australia.Season.4 + http://www.bitmetv.org/download.php/910/The%20Mole%20Australia%20-%20Season%204.torrent + Tue, 13 May 2014 15:52:54 -0000 + + Category: (Reality TV - Competitive) + Size: 2.13 GB + + + + diff --git a/src/NzbDrone.Core.Test/Files/RSS/Eztv.xml b/src/NzbDrone.Core.Test/Files/RSS/Eztv.xml new file mode 100644 index 000000000..c20dc8512 --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/RSS/Eztv.xml @@ -0,0 +1,63 @@ + + + + + ezRSS - Latest torrent releases + 15 + http://ezrss.it/feed/ + + ezRSS - Latest torrent releases + http://ezrss.it/images/ezrssit.png + http://ezrss.it/feed/ + + The latest 30 torrent releases. + + <![CDATA[S4C I Grombil Cyfandir Pell American Interior [PDTV - MVGROUP]]]> + http://re.zoink.it/20a4ed4eFC + + Mon, 15 Sep 2014 13:39:00 -0500 + + + http://eztv.it/forum/discuss/58439/ + http://eztv.it/ep/58439/s4c-i-grombil-cyfandir-pell-american-interior-pdtv-x264-mvgroup/ + + + 796606175 + 20FC4FBFA88272274AC671F857CC15144E9AA83E + + + + + <![CDATA[Andy McNabs Tour Of Duty Series 1 - Courage Under Fire 1x6 [DVDRIP - MVGROUP]]]> + http://re.zoink.it/AAa65f6eA2 + + Mon, 15 Sep 2014 13:04:21 -0500 + + + http://eztv.it/forum/discuss/58438/ + http://eztv.it/ep/58438/andy-mcnabs-tour-of-duty-series-1-6of6-courage-under-fire-dvdrip-x264-mvgroup/ + + + 698999946 + AAA2038BED9EBCA2C312D1C9C3E8E024D0EB414E + + + + + <![CDATA[So You Think You Can Drive [HDTV - MVGROUP]]]> + http://re.zoink.it/54a65da3D5 + + Mon, 15 Sep 2014 09:19:32 -0500 + + + http://eztv.it/forum/discuss/58437/ + http://eztv.it/ep/58437/so-you-think-you-can-drive-x264-hdtv-mvgroup/ + + + 1163302273 + 54D50B8352B2C54A1A3AD952269A56D2D95A3DF4 + + + + + \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/Files/RSS/IPTorrents.xml b/src/NzbDrone.Core.Test/Files/RSS/IPTorrents.xml new file mode 100644 index 000000000..df0076052 --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/RSS/IPTorrents.xml @@ -0,0 +1,35 @@ + + + + + 24 S03E12 720p WEBRip h264-DRAWER + http://iptorrents.com/download.php/1234/24.S03E12.720p.WEBRip.h264-DRAWER.torrent?torrent_pass=abcd + Mon, 12 May 2014 19:06:34 +0000 + Category: TV/x264 Size: 1.37 GB + + + Rosemary's Baby S01E01 Part 1 1080p WEB-DL DD5 1 H 264-BS + http://iptorrents.com/download.php/1234/Rosemary's.Baby.S01E01.Part.1.1080p.WEB-DL.DD5.1.H.264-BS.torrent?torrent_pass=abcd + Mon, 12 May 2014 19:06:25 +0000 + 556 MB; TV/x264 + + + Rosemary's Baby S01E01 Part 1 720p WEB-DL DD5 1 H 264-BS + http://iptorrents.com/download.php/1234/Rosemary's.Baby.S01E01.Part.1.720p.WEB-DL.DD5.1.H.264-BS.torrent?torrent_pass=abcd + Mon, 12 May 2014 19:04:09 +0000 + Category: TV/x264 Size: 2.65 GB + + + 24 S03E11 720p WEBRip h264-DRAWER + http://iptorrents.com/download.php/1234/24.S03E11.720p.WEBRip.h264-DRAWER.torrent?torrent_pass=abcd + Mon, 12 May 2014 19:02:54 +0000 + Category: TV/x264 Size: 1.33 GB + + + Da Vincis Demons S02E08 1080p WEB-DL DD5 1 H 264-BS + http://iptorrents.com/download.php/1234/Da.Vincis.Demons.S02E08.1080p.WEB-DL.DD5.1.H.264-BS.torrent?torrent_pass=abcd + Mon, 12 May 2014 19:02:11 +0000 + Category: TV/x264 Size: 1.92 GB + + + \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/Files/RSS/KickassTorrents.xml b/src/NzbDrone.Core.Test/Files/RSS/KickassTorrents.xml new file mode 100644 index 000000000..71b2ecfa4 --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/RSS/KickassTorrents.xml @@ -0,0 +1,93 @@ + + + + tv torrents RSS feed - KickassTorrents + http://kickass.to/ + tv torrents RSS feed + + Doctor Stranger.E03.140512.HDTV.H264.720p-iPOP.avi [CTRG] + TV + http://kickass.to/user/2NE1/ + http://kickass.to/doctor-stranger-e03-140512-hdtv-h264-720p-ipop-avi-ctrg-t9100648.html + http://kickass.to/doctor-stranger-e03-140512-hdtv-h264-720p-ipop-avi-ctrg-t9100648.html + Mon, 12 May 2014 16:16:49 +0000 + 1205364736 + 208C4F7866612CC88BFEBC7C496FA72C2368D1C0 + + 206 + 311 + 1 + doctor.stranger.e03.140512.hdtv.h264.720p.ipop.avi.ctrg.torrent + + + + Triangle.E03.140512.HDTV.XViD-iPOP.avi [CTRG] + TV + http://kickass.to/user/2NE1/ + http://kickass.to/triangle-e03-140512-hdtv-xvid-ipop-avi-ctrg-t9100647.html + http://kickass.to/triangle-e03-140512-hdtv-xvid-ipop-avi-ctrg-t9100647.html + Mon, 12 May 2014 16:16:31 +0000 + 677543936 + BF22A53C9889A7D325F2A3D904E566B7DF4074EB + + 242 + 374 + 1 + triangle.e03.140512.hdtv.xvid.ipop.avi.ctrg.torrent + + + + Triangle.E03.140512.HDTV.H264.720p-iPOP.avi [CTRG] + TV + http://kickass.to/user/2NE1/ + http://kickass.to/triangle-e03-140512-hdtv-h264-720p-ipop-avi-ctrg-t9100646.html + http://kickass.to/triangle-e03-140512-hdtv-h264-720p-ipop-avi-ctrg-t9100646.html + Mon, 12 May 2014 16:16:10 +0000 + 1196869632 + 8427BFB8884B8228364EBB9B3EA7D8B77E03A7BC + + 177 + 268 + 1 + triangle.e03.140512.hdtv.h264.720p.ipop.avi.ctrg.torrent + + + + Triangle.E03.140512.HDTV.X264.720p-BarosG_.avi [CTRG] + TV + http://kickass.to/user/2NE1/ + http://kickass.to/triangle-e03-140512-hdtv-x264-720p-barosg-avi-ctrg-t9100644.html + http://kickass.to/triangle-e03-140512-hdtv-x264-720p-barosg-avi-ctrg-t9100644.html + Mon, 12 May 2014 16:15:52 +0000 + 1418906266 + 5556B773893DB55287ECEC581E850B853163DB11 + + 522 + 785 + 1 + triangle.e03.140512.hdtv.x264.720p.barosg.avi.ctrg.torrent + + + + Battlestar Galactica 1978 Dvd3 e09 e10 e11 e12 [NL] [FR] [ENG] Sub + + + + TV + http://kickass.to/user/hendriknl/ + http://kickass.to/battlestar-galactica-1978-dvd3-e09-e10-e11-e12-nl-fr-eng-sub-t9100642.html + http://kickass.to/battlestar-galactica-1978-dvd3-e09-e10-e11-e12-nl-fr-eng-sub-t9100642.html + Mon, 12 May 2014 16:15:46 +0000 + 4680841216 + 3D293CAFEDAC595F6E55F9C284DD76862FE254F6 + + 2 + 5 + 0 + battlestar.galactica.1978.dvd3.e09.e10.e11.e12.nl.fr.eng.sub.torrent + + + + \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/Files/RSS/Nyaa.xml b/src/NzbDrone.Core.Test/Files/RSS/Nyaa.xml new file mode 100644 index 000000000..f13a4347c --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/RSS/Nyaa.xml @@ -0,0 +1,40 @@ + + + NyaaTorrents + http://www.nyaa.se/ + + + + [TSRaws] Futsuu no Joshikousei ga [Locodol] Yattemita. #07 (TBS).ts + Raw Anime + http://www.nyaa.se/?page=download&tid=587750 + http://www.nyaa.se/?page=view&tid=587750 + + Thu, 14 Aug 2014 18:10:36 +0000 + + + [JIGGYSUB] KOI KOI 7 EP07 [R2DVD 420P H264 AC3] + English-translated Anime + http://www.nyaa.se/?page=download&tid=587749 + http://www.nyaa.se/?page=view&tid=587749 + + Thu, 14 Aug 2014 18:05:22 +0000 + + + [Ohys-Raws] RAIL WARS! - 07 (TBS 1280x720 x264 AAC).mp4 + Raw Anime + http://www.nyaa.se/?page=download&tid=587748 + http://www.nyaa.se/?page=view&tid=587748 + + Thu, 14 Aug 2014 18:02:57 +0000 + + + [Arabasma.com] Naruto Shippuuden - 372 [Arabic Sub] [MQ].mp4 + Non-English-translated Anime + http://www.nyaa.se/?page=download&tid=587747 + http://www.nyaa.se/?page=view&tid=587747 + + Thu, 14 Aug 2014 18:01:36 +0000 + + + \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/Files/RSS/Torrentleech.xml b/src/NzbDrone.Core.Test/Files/RSS/Torrentleech.xml new file mode 100644 index 000000000..f2bd7eb7d --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/RSS/Torrentleech.xml @@ -0,0 +1,56 @@ + + + + TorrentLeech + http://www.torrentleech.org + The latest torrents from TorrentLeech.org + en + 5 + + + <![CDATA[Classic Car Rescue S02E04 720p HDTV x264-C4TV]]> + Mon, 12 May 2014 19:15:28 +0000 + Episodes HD + http://www.torrentleech.org/torrent/513575 + + + + + + <![CDATA[24 S03E14 720p WEBRip h264-DRAWER]]> + Mon, 12 May 2014 19:14:09 +0000 + Episodes HD + http://www.torrentleech.org/torrent/513574 + + + + + + <![CDATA[24 S03E13 720p WEBRip h264-DRAWER]]> + Mon, 12 May 2014 19:09:18 +0000 + Episodes HD + http://www.torrentleech.org/torrent/513573 + + + + + + <![CDATA[24 S03E11 720p WEBRip h264-DRAWER]]> + Mon, 12 May 2014 19:09:10 +0000 + Episodes HD + http://www.torrentleech.org/torrent/513572 + + + + + + <![CDATA[Meet Joe Black 1998 1080p HDDVD x264-FSiHD]]> + Mon, 12 May 2014 19:06:59 +0000 + HD + http://www.torrentleech.org/torrent/513571 + + + + + + \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/HealthCheck/Checks/ImportMechanismCheckFixture.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/ImportMechanismCheckFixture.cs index 4e0724e84..8260c03ee 100644 --- a/src/NzbDrone.Core.Test/HealthCheck/Checks/ImportMechanismCheckFixture.cs +++ b/src/NzbDrone.Core.Test/HealthCheck/Checks/ImportMechanismCheckFixture.cs @@ -39,7 +39,7 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks .With(v => v.State == TrackedDownloadState.Downloading) .With(v => v.DownloadItem = new DownloadClientItem()) .With(v => v.DownloadItem.Status = DownloadItemStatus.Completed) - .With(v => v.DownloadItem.OutputPath = @"C:\Test\DropFolder\myfile.mkv".AsOsAgnostic()) + .With(v => v.DownloadItem.OutputPath = new OsPath(@"C:\Test\DropFolder\myfile.mkv".AsOsAgnostic())) .Build(); Mocker.GetMock() @@ -78,7 +78,7 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks GivenCompletedDownloadHandling(true); GivenDroneFactoryFolder(true); - _completed.First().DownloadItem.OutputPath = (DRONE_FACTORY_FOLDER + @"\myfile.mkv").AsOsAgnostic(); + _completed.First().DownloadItem.OutputPath = new OsPath((DRONE_FACTORY_FOLDER + @"\myfile.mkv").AsOsAgnostic()); Subject.Check().ShouldBeWarning(); } diff --git a/src/NzbDrone.Core.Test/IndexerTests/BitMeTvTests/BitMeTVFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/BitMeTvTests/BitMeTVFixture.cs new file mode 100644 index 000000000..a6b1b6278 --- /dev/null +++ b/src/NzbDrone.Core.Test/IndexerTests/BitMeTvTests/BitMeTVFixture.cs @@ -0,0 +1,58 @@ +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Http; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Indexers.BitMeTv; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; +using System; +using System.Linq; +using FluentAssertions; + +namespace NzbDrone.Core.Test.IndexerTests.BitMeTvTests +{ + [TestFixture] + public class BitMeTvFixture : CoreTest + { + [SetUp] + public void Setup() + { + Subject.Definition = new IndexerDefinition() + { + Name = "BitMeTV", + Settings = new BitMeTvSettings() + }; + } + + [Test] + public void should_parse_recent_feed_from_BitMeTv() + { + var recentFeed = ReadAllText(@"Files/RSS/BitMeTv.xml"); + + + Mocker.GetMock() + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET))) + .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed)); + + var releases = Subject.FetchRecent(); + + releases.Should().HaveCount(5); + releases.First().Should().BeOfType(); + + var torrentInfo = releases.First() as TorrentInfo; + + torrentInfo.Title.Should().Be("Total.Divas.S02E08.HDTV.x264-CRiMSON"); + torrentInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent); + torrentInfo.DownloadUrl.Should().Be("http://www.bitmetv.org/download.php/12/Total.Divas.S02E08.HDTV.x264-CRiMSON.torrent"); + torrentInfo.InfoUrl.Should().BeNullOrEmpty(); + torrentInfo.CommentUrl.Should().BeNullOrEmpty(); + torrentInfo.Indexer.Should().Be(Subject.Definition.Name); + torrentInfo.PublishDate.Should().Be(DateTime.Parse("2014/05/13 17:04:29")); + torrentInfo.Size.Should().Be(395009065); + torrentInfo.InfoHash.Should().Be(null); + torrentInfo.MagnetUrl.Should().Be(null); + torrentInfo.Peers.Should().Be(null); + torrentInfo.Seeds.Should().Be(null); + } + } +} diff --git a/src/NzbDrone.Core.Test/IndexerTests/BroadcastheNetTests/BroadcastheNetFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/BroadcastheNetTests/BroadcastheNetFixture.cs new file mode 100644 index 000000000..58e3f86e1 --- /dev/null +++ b/src/NzbDrone.Core.Test/IndexerTests/BroadcastheNetTests/BroadcastheNetFixture.cs @@ -0,0 +1,137 @@ +using Moq; +using NUnit.Framework; +using NzbDrone.Common; +using NzbDrone.Common.Http; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Indexers.BroadcastheNet; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Test.Common; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using FluentAssertions; +using NzbDrone.Core.Indexers.Exceptions; + +namespace NzbDrone.Core.Test.IndexerTests.BroadcastheNetTests +{ + [TestFixture] + public class BroadcastheNetFixture : CoreTest + { + [SetUp] + public void Setup() + { + Subject.Definition = new IndexerDefinition() + { + Name = "BroadcastheNet", + Settings = new BroadcastheNetSettings() { ApiKey = "abc" } + }; + } + + [Test] + public void should_parse_recent_feed_from_BroadcastheNet() + { + var recentFeed = ReadAllText(@"Files/Indexers/BroadcastheNet/RecentFeed.json"); + + Mocker.GetMock() + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.POST))) + .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed)); + + var releases = Subject.FetchRecent(); + + releases.Should().HaveCount(2); + releases.First().Should().BeOfType(); + + var torrentInfo = releases.First() as TorrentInfo; + + torrentInfo.Guid.Should().Be("BTN-123"); + torrentInfo.Title.Should().Be("Jimmy.Kimmel.2014.09.15.Jane.Fonda.HDTV.x264-aAF"); + torrentInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent); + torrentInfo.DownloadUrl.Should().Be("https://broadcasthe.net/torrents.php?action=download&id=123&authkey=123&torrent_pass=123"); + torrentInfo.InfoUrl.Should().Be("https://broadcasthe.net/torrents.php?id=237457&torrentid=123"); + torrentInfo.CommentUrl.Should().BeNullOrEmpty(); + torrentInfo.Indexer.Should().Be(Subject.Definition.Name); + torrentInfo.PublishDate.Should().Be(DateTime.Parse("2014/09/16 21:15:33")); + torrentInfo.Size.Should().Be(505099926); + torrentInfo.InfoHash.Should().Be("123"); + torrentInfo.TvRageId.Should().Be(4055); + torrentInfo.MagnetUrl.Should().BeNullOrEmpty(); + torrentInfo.Peers.Should().Be(9); + torrentInfo.Seeds.Should().Be(40); + } + + private void VerifyBackOff() + { + // TODO How to detect (and implement) back-off logic. + } + + [Test] + public void should_back_off_on_bad_request() + { + Mocker.GetMock() + .Setup(v => v.Execute(It.IsAny())) + .Returns(r => new HttpResponse(r, new HttpHeader(), new Byte[0], System.Net.HttpStatusCode.BadRequest)); + + var results = Subject.FetchRecent(); + + results.Should().BeEmpty(); + + VerifyBackOff(); + + ExceptionVerification.ExpectedErrors(1); + } + + [Test] + public void should_back_off_and_report_api_key_invalid() + { + Mocker.GetMock() + .Setup(v => v.Execute(It.IsAny())) + .Returns(r => new HttpResponse(r, new HttpHeader(), new Byte[0], System.Net.HttpStatusCode.Unauthorized)); + + var results = Subject.FetchRecent(); + + results.Should().BeEmpty(); + + results.Should().BeEmpty(); + + VerifyBackOff(); + + ExceptionVerification.ExpectedWarns(1); + } + + [Test] + public void should_back_off_on_unknown_method() + { + Mocker.GetMock() + .Setup(v => v.Execute(It.IsAny())) + .Returns(r => new HttpResponse(r, new HttpHeader(), new Byte[0], System.Net.HttpStatusCode.NotFound)); + + var results = Subject.FetchRecent(); + + results.Should().BeEmpty(); + + VerifyBackOff(); + + ExceptionVerification.ExpectedErrors(1); + } + + [Test] + public void should_back_off_api_limit_reached() + { + Mocker.GetMock() + .Setup(v => v.Execute(It.IsAny())) + .Returns(r => new HttpResponse(r, new HttpHeader(), new Byte[0], System.Net.HttpStatusCode.ServiceUnavailable)); + + var results = Subject.FetchRecent(); + + results.Should().BeEmpty(); + + VerifyBackOff(); + + ExceptionVerification.ExpectedWarns(1); + } + } +} diff --git a/src/NzbDrone.Core.Test/IndexerTests/EztvTests/EztvFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/EztvTests/EztvFixture.cs new file mode 100644 index 000000000..1ff96d538 --- /dev/null +++ b/src/NzbDrone.Core.Test/IndexerTests/EztvTests/EztvFixture.cs @@ -0,0 +1,63 @@ +using Moq; +using NUnit.Framework; +using NzbDrone.Common; +using NzbDrone.Common.Http; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Indexers.Eztv; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Test.Common; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using FluentAssertions; + +namespace NzbDrone.Core.Test.IndexerTests.EztvTests +{ + [TestFixture] + public class EztvFixture : CoreTest + { + [SetUp] + public void Setup() + { + Subject.Definition = new IndexerDefinition() + { + Name = "Eztv", + Settings = new EztvSettings() + }; + } + + [Test] + public void should_parse_recent_feed_from_Eztv() + { + var recentFeed = ReadAllText(@"Files/RSS/Eztv.xml"); + + Mocker.GetMock() + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET))) + .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed)); + + var releases = Subject.FetchRecent(); + + releases.Should().HaveCount(3); + releases.First().Should().BeOfType(); + + var torrentInfo = releases.First() as TorrentInfo; + + torrentInfo.Title.Should().Be("S4C I Grombil Cyfandir Pell American Interior [PDTV - MVGROUP]"); + torrentInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent); + torrentInfo.DownloadUrl.Should().Be("http://re.zoink.it/20a4ed4eFC"); + torrentInfo.InfoUrl.Should().Be("http://eztv.it/ep/58439/s4c-i-grombil-cyfandir-pell-american-interior-pdtv-x264-mvgroup/"); + torrentInfo.CommentUrl.Should().Be("http://eztv.it/forum/discuss/58439/"); + torrentInfo.Indexer.Should().Be(Subject.Definition.Name); + torrentInfo.PublishDate.Should().Be(DateTime.Parse("2014/09/15 18:39:00")); + torrentInfo.Size.Should().Be(796606175); + torrentInfo.InfoHash.Should().Be("20FC4FBFA88272274AC671F857CC15144E9AA83E"); + torrentInfo.MagnetUrl.Should().Be("magnet:?xt=urn:btih:ED6E7P5IQJZCOSWGOH4FPTAVCRHJVKB6&dn=S4C.I.Grombil.Cyfandir.Pell.American.Interior.PDTV.x264-MVGroup"); + torrentInfo.Peers.Should().NotHaveValue(); + torrentInfo.Seeds.Should().NotHaveValue(); + } + } +} diff --git a/src/NzbDrone.Core.Test/IndexerTests/IPTorrentsTests/IPTorrentsFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/IPTorrentsTests/IPTorrentsFixture.cs new file mode 100644 index 000000000..a3aeb8ce1 --- /dev/null +++ b/src/NzbDrone.Core.Test/IndexerTests/IPTorrentsTests/IPTorrentsFixture.cs @@ -0,0 +1,63 @@ +using Moq; +using NUnit.Framework; +using NzbDrone.Common; +using NzbDrone.Common.Http; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Indexers.IPTorrents; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Test.Common; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using FluentAssertions; + +namespace NzbDrone.Core.Test.IndexerTests.IPTorrentsTests +{ + [TestFixture] + public class IPTorrentsFixture : CoreTest + { + [SetUp] + public void Setup() + { + Subject.Definition = new IndexerDefinition() + { + Name = "IPTorrents", + Settings = new IPTorrentsSettings() { Url = "http://fake.com/" } + }; + } + + [Test] + public void should_parse_recent_feed_from_IPTorrents() + { + var recentFeed = ReadAllText(@"Files/RSS/IPTorrents.xml"); + + Mocker.GetMock() + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET))) + .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed)); + + var releases = Subject.FetchRecent(); + + releases.Should().HaveCount(5); + releases.First().Should().BeOfType(); + + var torrentInfo = releases.First() as TorrentInfo; + + torrentInfo.Title.Should().Be("24 S03E12 720p WEBRip h264-DRAWER"); + torrentInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent); + torrentInfo.DownloadUrl.Should().Be("http://iptorrents.com/download.php/1234/24.S03E12.720p.WEBRip.h264-DRAWER.torrent?torrent_pass=abcd"); + torrentInfo.InfoUrl.Should().BeNullOrEmpty(); + torrentInfo.CommentUrl.Should().BeNullOrEmpty(); + torrentInfo.Indexer.Should().Be(Subject.Definition.Name); + torrentInfo.PublishDate.Should().Be(DateTime.Parse("2014/05/12 19:06:34")); + torrentInfo.Size.Should().Be(1471026299); + torrentInfo.InfoHash.Should().Be(null); + torrentInfo.MagnetUrl.Should().Be(null); + torrentInfo.Peers.Should().Be(null); + torrentInfo.Seeds.Should().Be(null); + } + } +} diff --git a/src/NzbDrone.Core.Test/IndexerTests/IntegrationTests/IndexerIntegrationTests.cs b/src/NzbDrone.Core.Test/IndexerTests/IntegrationTests/IndexerIntegrationTests.cs index fd6a7b9c2..cd2fcd964 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/IntegrationTests/IndexerIntegrationTests.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/IntegrationTests/IndexerIntegrationTests.cs @@ -1,69 +1,211 @@ using System.Collections.Generic; +using System.Linq; using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Common; using NzbDrone.Core.Indexers; +using NzbDrone.Core.Indexers.Eztv; +using NzbDrone.Core.Indexers.Fanzub; +using NzbDrone.Core.Indexers.KickassTorrents; using NzbDrone.Core.Indexers.Newznab; +using NzbDrone.Core.Indexers.Nyaa; using NzbDrone.Core.Indexers.Wombles; +using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Test.Framework; -using NUnit.Framework; using NzbDrone.Core.ThingiProvider; using NzbDrone.Test.Common.Categories; -using System.Linq; namespace NzbDrone.Core.Test.IndexerTests.IntegrationTests { [IntegrationTest] - public class IndexerIntegrationTests : CoreTest + public class IndexerIntegrationTests : CoreTest { + private SingleEpisodeSearchCriteria _singleSearchCriteria; + private AnimeEpisodeSearchCriteria _animeSearchCriteria; + [SetUp] public void SetUp() { UseRealHttp(); + + _singleSearchCriteria = new SingleEpisodeSearchCriteria() + { + SceneTitles = new List { "Person of Interest" }, + SeasonNumber = 1, + EpisodeNumber = 1 + }; + + _animeSearchCriteria = new AnimeEpisodeSearchCriteria() + { + SceneTitles = new List { "Steins;Gate" }, + AbsoluteEpisodeNumber = 1 + }; } [Test] - public void wombles_rss() + public void wombles_fetch_recent() { - Subject.Definition = new IndexerDefinition + var indexer = Mocker.Resolve(); + + indexer.Definition = new IndexerDefinition { - Name = "Wombles", + Name = "MyIndexer", Settings = NullConfig.Instance }; - var result = Subject.FetchRecent(); + var result = indexer.FetchRecent(); - ValidateResult(result, skipSize: true, skipInfo: true); + ValidateResult(result); } - - private void ValidateResult(IList reports, bool skipSize = false, bool skipInfo = false) + + [Test] + public void fanzub_fetch_recent() + { + Assert.Inconclusive("Fanzub Down"); + + var indexer = Mocker.Resolve(); + + indexer.Definition = new IndexerDefinition + { + Name = "MyIndexer", + Settings = NullConfig.Instance + }; + + var result = indexer.FetchRecent(); + + ValidateResult(result); + } + + [Test] + public void fanzub_search_single() + { + Assert.Inconclusive("Fanzub Down"); + + var indexer = Mocker.Resolve(); + + indexer.Definition = new IndexerDefinition + { + Name = "MyIndexer", + Settings = NullConfig.Instance + }; + + var result = indexer.Fetch(_animeSearchCriteria); + + ValidateResult(result); + } + + [Test] + public void kickass_fetch_recent() + { + var indexer = Mocker.Resolve(); + + indexer.Definition = new IndexerDefinition + { + Name = "MyIndexer", + Settings = new KickassTorrentsSettings() + }; + + var result = indexer.FetchRecent(); + + ValidateTorrentResult(result, hasSize: true); + } + + [Test] + public void kickass_search_single() + { + var indexer = Mocker.Resolve(); + + indexer.Definition = new IndexerDefinition + { + Name = "MyIndexer", + Settings = new KickassTorrentsSettings() + }; + + var result = indexer.Fetch(_singleSearchCriteria); + + ValidateTorrentResult(result, hasSize: true, hasMagnet: true); + } + + [Test] + public void eztv_fetch_recent() + { + var indexer = Mocker.Resolve(); + + indexer.Definition = new IndexerDefinition + { + Name = "MyIndexer", + Settings = new EztvSettings() + }; + + var result = indexer.FetchRecent(); + + ValidateTorrentResult(result, hasSize: true, hasMagnet: true); + } + + [Test] + public void nyaa_fetch_recent() + { + var indexer = Mocker.Resolve(); + + indexer.Definition = new IndexerDefinition + { + Name = "MyIndexer", + Settings = new NyaaSettings() + }; + + var result = indexer.FetchRecent(); + + ValidateTorrentResult(result, hasSize: true); + } + + [Test] + public void nyaa_search_single() + { + var indexer = Mocker.Resolve(); + + indexer.Definition = new IndexerDefinition + { + Name = "MyIndexer", + Settings = new NyaaSettings() + }; + + var result = indexer.Fetch(_animeSearchCriteria); + + ValidateTorrentResult(result, hasSize: true); + } + + private void ValidateResult(IList reports, bool hasSize = false, bool hasInfoUrl = false) { reports.Should().NotBeEmpty(); - reports.Should().NotContain(c => string.IsNullOrWhiteSpace(c.Title)); - reports.Should().NotContain(c => string.IsNullOrWhiteSpace(c.DownloadUrl)); + reports.Should().OnlyContain(c => c.Title.IsNotNullOrWhiteSpace()); reports.Should().OnlyContain(c => c.PublishDate.Year > 2000); + reports.Should().OnlyContain(c => c.DownloadUrl.IsNotNullOrWhiteSpace()); reports.Should().OnlyContain(c => c.DownloadUrl.StartsWith("http")); - if (!skipInfo) + if (hasInfoUrl) { - reports.Should().NotContain(c => string.IsNullOrWhiteSpace(c.InfoUrl)); + reports.Should().OnlyContain(c => c.InfoUrl.IsNotNullOrWhiteSpace()); } - if (!skipSize) + if (hasSize) { reports.Should().OnlyContain(c => c.Size > 0); } } - private void ValidateTorrentResult(IList reports, bool skipSize = false, bool skipInfo = false) + private void ValidateTorrentResult(IList reports, bool hasSize = false, bool hasInfoUrl = false, bool hasMagnet = false) { reports.Should().OnlyContain(c => c.GetType() == typeof(TorrentInfo)); - ValidateResult(reports, skipSize, skipInfo); + ValidateResult(reports, hasSize, hasInfoUrl); - reports.Should().OnlyContain(c => c.DownloadUrl.EndsWith(".torrent")); + reports.Should().OnlyContain(c => c.DownloadProtocol == DownloadProtocol.Torrent); - reports.Cast().Should().OnlyContain(c => c.MagnetUrl.StartsWith("magnet:")); - reports.Cast().Should().NotContain(c => string.IsNullOrWhiteSpace(c.InfoHash)); + if (hasMagnet) + { + reports.Cast().Should().OnlyContain(c => c.MagnetUrl.StartsWith("magnet:")); + } } } diff --git a/src/NzbDrone.Core.Test/IndexerTests/KickassTorrentsTests/KickassTorrentsFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/KickassTorrentsTests/KickassTorrentsFixture.cs new file mode 100644 index 000000000..361dd263a --- /dev/null +++ b/src/NzbDrone.Core.Test/IndexerTests/KickassTorrentsTests/KickassTorrentsFixture.cs @@ -0,0 +1,93 @@ +using Moq; +using NUnit.Framework; +using NzbDrone.Common; +using NzbDrone.Common.Http; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Indexers.KickassTorrents; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Test.Common; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using FluentAssertions; + +namespace NzbDrone.Core.Test.IndexerTests.KickassTorrentsTests +{ + [TestFixture] + public class KickassTorrentsFixture : CoreTest + { + [SetUp] + public void Setup() + { + Subject.Definition = new IndexerDefinition() + { + Name = "Kickass Torrents", + Settings = new KickassTorrentsSettings() { VerifiedOnly = false } + }; + } + + [Test] + public void should_parse_recent_feed_from_KickassTorrents() + { + var recentFeed = ReadAllText(@"Files/RSS/KickassTorrents.xml"); + + Mocker.GetMock() + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET))) + .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed)); + + var releases = Subject.FetchRecent(); + + releases.Should().HaveCount(5); + releases.First().Should().BeOfType(); + + var torrentInfo = releases.First() as TorrentInfo; + + torrentInfo.Title.Should().Be("Doctor Stranger.E03.140512.HDTV.H264.720p-iPOP.avi [CTRG]"); + torrentInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent); + torrentInfo.DownloadUrl.Should().Be("http://torcache.net/torrent/208C4F7866612CC88BFEBC7C496FA72C2368D1C0.torrent?title=[kickass.to]doctor.stranger.e03.140512.hdtv.h264.720p.ipop.avi.ctrg"); + torrentInfo.InfoUrl.Should().Be("http://kickass.to/doctor-stranger-e03-140512-hdtv-h264-720p-ipop-avi-ctrg-t9100648.html"); + torrentInfo.CommentUrl.Should().BeNullOrEmpty(); + torrentInfo.Indexer.Should().Be(Subject.Definition.Name); + torrentInfo.PublishDate.Should().Be(DateTime.Parse("2014/05/12 16:16:49")); + torrentInfo.Size.Should().Be(1205364736); + torrentInfo.InfoHash.Should().Be("208C4F7866612CC88BFEBC7C496FA72C2368D1C0"); + torrentInfo.MagnetUrl.Should().Be("magnet:?xt=urn:btih:208C4F7866612CC88BFEBC7C496FA72C2368D1C0&dn=doctor+stranger+e03+140512+hdtv+h264+720p+ipop+avi+ctrg&tr=udp%3A%2F%2Fopen.demonii.com%3A1337%2Fannounce"); + torrentInfo.Peers.Should().Be(311); + torrentInfo.Seeds.Should().Be(206); + } + + [Test] + public void should_return_empty_list_on_404() + { + Mocker.GetMock() + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET))) + .Returns(r => new HttpResponse(r, new HttpHeader(), new Byte[0], System.Net.HttpStatusCode.NotFound)); + + var releases = Subject.FetchRecent(); + + releases.Should().HaveCount(0); + + ExceptionVerification.IgnoreWarns(); + } + + [Test] + public void should_not_return_unverified_releases_if_not_configured() + { + (Subject.Definition.Settings as KickassTorrentsSettings).VerifiedOnly = true; + + var recentFeed = ReadAllText(@"Files/RSS/KickassTorrents.xml"); + + Mocker.GetMock() + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET))) + .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed)); + + var releases = Subject.FetchRecent(); + + releases.Should().HaveCount(4); + } + } +} diff --git a/src/NzbDrone.Core.Test/IndexerTests/NyaaTests/NyaaFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/NyaaTests/NyaaFixture.cs new file mode 100644 index 000000000..6d48a6b42 --- /dev/null +++ b/src/NzbDrone.Core.Test/IndexerTests/NyaaTests/NyaaFixture.cs @@ -0,0 +1,57 @@ +using System; +using System.Linq; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Http; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Indexers.Nyaa; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.IndexerTests.NyaaTests +{ + [TestFixture] + public class NyaaFixture : CoreTest + { + [SetUp] + public void Setup() + { + Subject.Definition = new IndexerDefinition() + { + Name = "Nyaa", + Settings = new NyaaSettings() + }; + } + + [Test] + public void should_parse_recent_feed_from_Nyaa() + { + var recentFeed = ReadAllText(@"Files/RSS/Nyaa.xml"); + + Mocker.GetMock() + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET))) + .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed)); + + var releases = Subject.FetchRecent(); + + releases.Should().HaveCount(4); + releases.First().Should().BeOfType(); + + var torrentInfo = releases.First() as TorrentInfo; + + torrentInfo.Title.Should().Be("[TSRaws] Futsuu no Joshikousei ga [Locodol] Yattemita. #07 (TBS).ts"); + torrentInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent); + torrentInfo.DownloadUrl.Should().Be("http://www.nyaa.se/?page=download&tid=587750"); + torrentInfo.InfoUrl.Should().Be("http://www.nyaa.se/?page=view&tid=587750"); + torrentInfo.CommentUrl.Should().BeNullOrEmpty(); + torrentInfo.Indexer.Should().Be(Subject.Definition.Name); + torrentInfo.PublishDate.Should().Be(DateTime.Parse("2014/08/14 18:10:36")); + torrentInfo.Size.Should().Be(2523293286); //2.35 GiB + torrentInfo.InfoHash.Should().Be(null); + torrentInfo.MagnetUrl.Should().Be(null); + torrentInfo.Peers.Should().Be(2); + torrentInfo.Seeds.Should().Be(1); + } + } +} diff --git a/src/NzbDrone.Core.Test/IndexerTests/TorrentleechTests/TorrentleechFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/TorrentleechTests/TorrentleechFixture.cs new file mode 100644 index 000000000..b95bad76d --- /dev/null +++ b/src/NzbDrone.Core.Test/IndexerTests/TorrentleechTests/TorrentleechFixture.cs @@ -0,0 +1,63 @@ +using Moq; +using NUnit.Framework; +using NzbDrone.Common; +using NzbDrone.Common.Http; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Indexers.Torrentleech; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Test.Common; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using FluentAssertions; + +namespace NzbDrone.Core.Test.IndexerTests.TorrentleechTests +{ + [TestFixture] + public class TorrentleechFixture : CoreTest + { + [SetUp] + public void Setup() + { + Subject.Definition = new IndexerDefinition() + { + Name = "Torrentleech", + Settings = new TorrentleechSettings() + }; + } + + [Test] + public void should_parse_recent_feed_from_Torrentleech() + { + var recentFeed = ReadAllText(@"Files/RSS/Torrentleech.xml"); + + Mocker.GetMock() + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET))) + .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed)); + + var releases = Subject.FetchRecent(); + + releases.Should().HaveCount(5); + releases.First().Should().BeOfType(); + + var torrentInfo = releases.First() as TorrentInfo; + + torrentInfo.Title.Should().Be("Classic Car Rescue S02E04 720p HDTV x264-C4TV"); + torrentInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent); + torrentInfo.DownloadUrl.Should().Be("http://www.torrentleech.org/rss/download/513575/1234/Classic.Car.Rescue.S02E04.720p.HDTV.x264-C4TV.torrent"); + torrentInfo.InfoUrl.Should().Be("http://www.torrentleech.org/torrent/513575"); + torrentInfo.CommentUrl.Should().Be("http://www.torrentleech.org/torrent/513575#comments"); + torrentInfo.Indexer.Should().Be(Subject.Definition.Name); + torrentInfo.PublishDate.Should().Be(DateTime.Parse("2014/05/12 19:15:28")); + torrentInfo.Size.Should().Be(0); + torrentInfo.InfoHash.Should().Be(null); + torrentInfo.MagnetUrl.Should().Be(null); + torrentInfo.Peers.Should().Be(7); + torrentInfo.Seeds.Should().Be(1); + } + } +} diff --git a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj index 91dde053d..623954a77 100644 --- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj @@ -140,11 +140,15 @@ + + + + @@ -180,6 +184,9 @@ + + + @@ -190,6 +197,10 @@ + + + + @@ -328,9 +339,16 @@ sqlite3.dll Always + + Always + Always + + Always + + App.config @@ -359,6 +377,9 @@ Always + + Always + Always @@ -396,6 +417,18 @@ Always Designer + + Always + + + Always + + + Always + + + Always + diff --git a/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs index 892dc667d..9b6314158 100644 --- a/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs @@ -36,6 +36,8 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("Hawaii Five 0", "hawaiifive0")] [TestCase("Match of the Day", "matchday")] [TestCase("Match of the Day 2", "matchday2")] + [TestCase("[ www.Torrenting.com ] - Revenge.S03E14.720p.HDTV.X264-DIMENSION", "Revenge")] + [TestCase("Seed S02E09 HDTV x264-2HD [eztv]-[rarbg.com]", "Seed")] public void should_parse_series_name(String postTitle, String title) { var result = Parser.Parser.ParseSeriesName(postTitle); @@ -50,6 +52,12 @@ namespace NzbDrone.Core.Test.ParserTests title.CleanSeriesTitle().Should().Be("carnivale"); } + [TestCase("Discovery TV - Gold Rush : 02 Road From Hell [S04].mp4")] + public void should_clean_up_invalid_path_characters(String postTitle) + { + Parser.Parser.ParseTitle(postTitle); + } + [TestCase("[scnzbefnet][509103] 2.Broke.Girls.S03E18.720p.HDTV.X264-DIMENSION", "2 Broke Girls")] public void should_remove_request_info_from_title(String postTitle, String title) { diff --git a/src/NzbDrone.Core.Test/ParserTests/ReleaseGroupParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ReleaseGroupParserFixture.cs index 156217e97..481f40696 100644 --- a/src/NzbDrone.Core.Test/ParserTests/ReleaseGroupParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/ReleaseGroupParserFixture.cs @@ -23,6 +23,8 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("Real Time with Bill Maher S12E17 May 23, 2014.mp4", null)] [TestCase("Reizen Waes - S01E08 - Transistrië, Zuid-Ossetië en Abchazië SDTV.avi", null)] [TestCase("Simpsons 10x11 - Wild Barts Cant Be Broken [rl].avi", null)] + [TestCase("[ www.Torrenting.com ] - Revenge.S03E14.720p.HDTV.X264-DIMENSION", "DIMENSION")] + [TestCase("Seed S02E09 HDTV x264-2HD [eztv]-[rarbg.com]", "2HD")] public void should_parse_release_group(string title, string expected) { Parser.Parser.ParseReleaseGroup(title).Should().Be(expected); diff --git a/src/NzbDrone.Core.Test/ParserTests/SingleEpisodeParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/SingleEpisodeParserFixture.cs index 4c9f2cafd..aaac796d5 100644 --- a/src/NzbDrone.Core.Test/ParserTests/SingleEpisodeParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/SingleEpisodeParserFixture.cs @@ -21,7 +21,6 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("Parenthood.2010.S02E14.HDTV.XviD-LOL", "Parenthood 2010", 2, 14)] [TestCase("Hawaii Five 0 S01E19 720p WEB DL DD5 1 H 264 NT", "Hawaii Five 0", 1, 19)] [TestCase("The Event S01E14 A Message Back 720p WEB DL DD5 1 H264 SURFER", "The Event", 1, 14)] - [TestCase("Constantine S1-E1-WEB-DL-1080p-NZBgeek", "Constantine", 1, 1)] [TestCase("Adam Hills In Gordon St Tonight S01E07 WS PDTV XviD FUtV", "Adam Hills In Gordon St Tonight", 1, 7)] [TestCase("Adventure.Inc.S03E19.DVDRip.XviD-OSiTV", "Adventure.Inc", 3, 19)] [TestCase("S03E09 WS PDTV XviD FUtV", "", 3, 9)] @@ -92,6 +91,8 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("Constantine S1-E1-WEB-DL-1080p-NZBgeek", "Constantine", 1, 1)] [TestCase("Constantine S1E1-WEB-DL-1080p-NZBgeek", "Constantine", 1, 1)] [TestCase("NCIS.S010E16.720p.HDTV.X264-DIMENSION", "NCIS", 10, 16)] + [TestCase("[ www.Torrenting.com ] - Revolution.2012.S02E17.720p.HDTV.X264-DIMENSION", "Revolution2012", 2, 17)] + [TestCase("Revolution.2012.S02E18.720p.HDTV.X264-DIMENSION.mkv", "Revolution2012", 2, 18)] //[TestCase("", "", 0, 0)] public void should_parse_single_episode(string postTitle, string title, int seasonNumber, int episodeNumber) { diff --git a/src/NzbDrone.Core.Test/RemotePathMappingsTests/RemotePathMappingServiceFixture.cs b/src/NzbDrone.Core.Test/RemotePathMappingsTests/RemotePathMappingServiceFixture.cs index 36ff6d577..872ca995b 100644 --- a/src/NzbDrone.Core.Test/RemotePathMappingsTests/RemotePathMappingServiceFixture.cs +++ b/src/NzbDrone.Core.Test/RemotePathMappingsTests/RemotePathMappingServiceFixture.cs @@ -26,6 +26,10 @@ namespace NzbDrone.Core.Test.RemotePathMappingsTests Mocker.GetMock() .Setup(s => s.All()) .Returns(new List()); + + Mocker.GetMock() + .Setup(s => s.Insert(It.IsAny())) + .Returns(m => m); } private void GivenMapping() @@ -93,13 +97,13 @@ namespace NzbDrone.Core.Test.RemotePathMappingsTests GivenMapping(); - var result = Subject.RemapRemoteToLocal(host, remotePath); + var result = Subject.RemapRemoteToLocal(host, new OsPath(remotePath)); result.Should().Be(expectedLocalPath); } [TestCase("my-server.localdomain", "/mnt/storage/downloads/tv", @"D:\mountedstorage\downloads\tv")] - [TestCase("my-server.localdomain", "/mnt/storage", @"D:\mountedstorage")] + [TestCase("my-server.localdomain", "/mnt/storage/", @"D:\mountedstorage")] [TestCase("my-2server.localdomain", "/mnt/storage/downloads/tv", "/mnt/storage/downloads/tv")] [TestCase("my-server.localdomain", "/mnt/storageabc/downloads/tv", "/mnt/storageabc/downloads/tv")] public void should_remap_local_to_remote(String host, String expectedRemotePath, String localPath) @@ -108,9 +112,28 @@ namespace NzbDrone.Core.Test.RemotePathMappingsTests GivenMapping(); - var result = Subject.RemapLocalToRemote(host, localPath); + var result = Subject.RemapLocalToRemote(host, new OsPath(localPath)); result.Should().Be(expectedRemotePath); } + + [TestCase(@"\\server\share\with/mixed/slashes", @"\\server\share\with\mixed\slashes\")] + [TestCase(@"D:/with/forward/slashes", @"D:\with\forward\slashes\")] + [TestCase(@"D:/with/mixed\slashes", @"D:\with\mixed\slashes\")] + public void should_fix_wrong_slashes_on_add(String remotePath, String cleanedPath) + { + GivenMapping(); + + var mapping = new RemotePathMapping + { + Host = "my-server.localdomain", + RemotePath = remotePath, + LocalPath = @"D:\mountedstorage\downloads\tv" .AsOsAgnostic() + }; + + var result = Subject.Add(mapping); + + result.RemotePath.Should().Be(cleanedPath); + } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Configuration/ConfigService.cs b/src/NzbDrone.Core/Configuration/ConfigService.cs index 8293d066a..39766b4bb 100644 --- a/src/NzbDrone.Core/Configuration/ConfigService.cs +++ b/src/NzbDrone.Core/Configuration/ConfigService.cs @@ -212,6 +212,13 @@ namespace NzbDrone.Core.Configuration set { SetValue("SkipFreeSpaceCheckWhenImporting", value); } } + public Boolean CopyUsingHardlinks + { + get { return GetValueBoolean("CopyUsingHardlinks", true); } + + set { SetValue("CopyUsingHardlinks", value); } + } + public Boolean SetPermissionsLinux { get { return GetValueBoolean("SetPermissionsLinux", false); } diff --git a/src/NzbDrone.Core/Configuration/IConfigService.cs b/src/NzbDrone.Core/Configuration/IConfigService.cs index 5dd81fc4b..93d298cfd 100644 --- a/src/NzbDrone.Core/Configuration/IConfigService.cs +++ b/src/NzbDrone.Core/Configuration/IConfigService.cs @@ -36,6 +36,7 @@ namespace NzbDrone.Core.Configuration Boolean CreateEmptySeriesFolders { get; set; } FileDateType FileDate { get; set; } Boolean SkipFreeSpaceCheckWhenImporting { get; set; } + Boolean CopyUsingHardlinks { get; set; } //Permissions (Media Management) Boolean SetPermissionsLinux { get; set; } diff --git a/src/NzbDrone.Core/Datastore/Converters/OsPathConverter.cs b/src/NzbDrone.Core/Datastore/Converters/OsPathConverter.cs new file mode 100644 index 000000000..9e460300c --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Converters/OsPathConverter.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Marr.Data.Converters; +using Marr.Data.Mapping; +using Newtonsoft.Json; +using NzbDrone.Common.Disk; + +namespace NzbDrone.Core.Datastore.Converters +{ + public class OsPathConverter : IConverter + { + public Object FromDB(ConverterContext context) + { + if (context.DbValue == DBNull.Value) + { + return DBNull.Value; + } + + var value = (String)context.DbValue; + + return new OsPath(value); + } + + public Object FromDB(ColumnMap map, Object dbValue) + { + return FromDB(new ConverterContext { ColumnMap = map, DbValue = dbValue }); + } + + public Object ToDB(Object clrValue) + { + var value = (OsPath)clrValue; + + return value.FullPath; + } + + public Type DbType + { + get { return typeof(String); } + } + } +} diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index 875ff2420..f6f90609f 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -28,6 +28,7 @@ using NzbDrone.Core.SeriesStats; using NzbDrone.Core.Tags; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Tv; +using NzbDrone.Common.Disk; namespace NzbDrone.Core.Datastore { @@ -114,6 +115,7 @@ namespace NzbDrone.Core.Datastore MapRepository.Instance.RegisterTypeConverter(typeof(ParsedEpisodeInfo), new EmbeddedDocumentConverter()); MapRepository.Instance.RegisterTypeConverter(typeof(ReleaseInfo), new EmbeddedDocumentConverter()); MapRepository.Instance.RegisterTypeConverter(typeof(HashSet), new EmbeddedDocumentConverter()); + MapRepository.Instance.RegisterTypeConverter(typeof(OsPath), new OsPathConverter()); } private static void RegisterProviderSettingConverter() diff --git a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionPriorizationService.cs b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionPriorizationService.cs index dbc83efdd..191e60d3e 100644 --- a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionPriorizationService.cs +++ b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionPriorizationService.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using System.Collections.Generic; +using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Qualities; namespace NzbDrone.Core.DecisionEngine @@ -19,7 +20,9 @@ namespace NzbDrone.Core.DecisionEngine .GroupBy(c => c.RemoteEpisode.Series.Id, (i, s) => s .OrderByDescending(c => c.RemoteEpisode.ParsedEpisodeInfo.Quality, new QualityModelComparer(s.First().RemoteEpisode.Series.Profile)) .ThenBy(c => c.RemoteEpisode.Episodes.Select(e => e.EpisodeNumber).MinOrDefault()) + .ThenBy(c => c.RemoteEpisode.Release.DownloadProtocol) .ThenBy(c => c.RemoteEpisode.Release.Size.Round(200.Megabytes()) / Math.Max(1, c.RemoteEpisode.Episodes.Count)) + .ThenByDescending(c => TorrentInfo.GetSeeders(c.RemoteEpisode.Release)) .ThenBy(c => c.RemoteEpisode.Release.Age)) .SelectMany(c => c) .Union(decisions.Where(c => c.RemoteEpisode.Series == null)) diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/RetentionSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/RetentionSpecification.cs index 66edfe97e..449b1c4ab 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/RetentionSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/RetentionSpecification.cs @@ -20,6 +20,12 @@ namespace NzbDrone.Core.DecisionEngine.Specifications public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) { + if (subject.Release.DownloadProtocol != Indexers.DownloadProtocol.Usenet) + { + _logger.Debug("Not checking retention requirement for non-usenet report"); + return Decision.Accept(); + } + var age = subject.Release.Age; var retention = _configService.Retention; diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/Search/TorrentSeedingSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/Search/TorrentSeedingSpecification.cs new file mode 100644 index 000000000..12ac5e7ab --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/Search/TorrentSeedingSpecification.cs @@ -0,0 +1,43 @@ +using NLog; +using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.DecisionEngine.Specifications.Search +{ + public class TorrentSeedingSpecification : IDecisionEngineSpecification + { + private readonly Logger _logger; + + public TorrentSeedingSpecification(Logger logger) + { + _logger = logger; + } + + public RejectionType Type + { + get + { + return RejectionType.Permanent; + } + } + + + public Decision IsSatisfiedBy(RemoteEpisode remoteEpisode, SearchCriteriaBase searchCriteria) + { + var torrentInfo = remoteEpisode.Release as TorrentInfo; + + if (torrentInfo == null) + { + return Decision.Accept(); + } + + if (torrentInfo.Seeds != null && torrentInfo.Seeds < 1) + { + _logger.Debug("Not enough seeders. ({0})", torrentInfo.Seeds); + return Decision.Reject("Not enough seeders. ({0})", torrentInfo.Seeds); + } + + return Decision.Accept(); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Download/Clients/Deluge/Deluge.cs b/src/NzbDrone.Core/Download/Clients/Deluge/Deluge.cs new file mode 100644 index 000000000..e624a2b54 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Deluge/Deluge.cs @@ -0,0 +1,303 @@ +using System; +using System.IO; +using System.Linq; +using System.Collections.Generic; +using NzbDrone.Common; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Http; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.MediaFiles.TorrentInfo; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Validation; +using NLog; +using Omu.ValueInjecter; +using FluentValidation.Results; +using System.Net; +using NzbDrone.Core.RemotePathMappings; + +namespace NzbDrone.Core.Download.Clients.Deluge +{ + public class Deluge : TorrentClientBase + { + private readonly IDelugeProxy _proxy; + + public Deluge(IDelugeProxy proxy, + ITorrentFileInfoReader torrentFileInfoReader, + IHttpClient httpClient, + IConfigService configService, + IDiskProvider diskProvider, + IParsingService parsingService, + IRemotePathMappingService remotePathMappingService, + Logger logger) + : base(torrentFileInfoReader, httpClient, configService, diskProvider, parsingService, remotePathMappingService, logger) + { + _proxy = proxy; + } + + protected override String AddFromMagnetLink(RemoteEpisode remoteEpisode, String hash, String magnetLink) + { + var actualHash = _proxy.AddTorrentFromMagnet(magnetLink, Settings); + + if (!Settings.TvCategory.IsNullOrWhiteSpace()) + { + _proxy.SetLabel(actualHash, Settings.TvCategory, Settings); + } + + _proxy.SetTorrentConfiguration(actualHash, "remove_at_ratio", false, Settings); + + var isRecentEpisode = remoteEpisode.IsRecentEpisode(); + + if (isRecentEpisode && Settings.RecentTvPriority == (int)DelugePriority.First || + !isRecentEpisode && Settings.OlderTvPriority == (int)DelugePriority.First) + { + _proxy.MoveTorrentToTopInQueue(actualHash, Settings); + } + + return actualHash.ToUpper(); + } + + protected override String AddFromTorrentFile(RemoteEpisode remoteEpisode, String hash, String filename, Byte[] fileContent) + { + var actualHash = _proxy.AddTorrentFromFile(filename, fileContent, Settings); + + if (!Settings.TvCategory.IsNullOrWhiteSpace()) + { + _proxy.SetLabel(actualHash, Settings.TvCategory, Settings); + } + + _proxy.SetTorrentConfiguration(actualHash, "remove_at_ratio", false, Settings); + + var isRecentEpisode = remoteEpisode.IsRecentEpisode(); + + if (isRecentEpisode && Settings.RecentTvPriority == (int)DelugePriority.First || + !isRecentEpisode && Settings.OlderTvPriority == (int)DelugePriority.First) + { + _proxy.MoveTorrentToTopInQueue(actualHash, Settings); + } + + return actualHash.ToUpper(); + } + + public override IEnumerable GetItems() + { + IEnumerable torrents; + + try + { + if (!Settings.TvCategory.IsNullOrWhiteSpace()) + { + torrents = _proxy.GetTorrentsByLabel(Settings.TvCategory, Settings); + } + else + { + torrents = _proxy.GetTorrents(Settings); + } + } + catch (DownloadClientException ex) + { + _logger.ErrorException(ex.Message, ex); + return Enumerable.Empty(); + } + + var items = new List(); + + foreach (var torrent in torrents) + { + var item = new DownloadClientItem(); + item.DownloadClientId = torrent.Hash.ToUpper(); + item.Title = torrent.Name; + item.Category = Settings.TvCategory; + + item.DownloadClient = Definition.Name; + item.DownloadTime = TimeSpan.FromSeconds(torrent.SecondsDownloading); + + var outputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(torrent.DownloadPath)); + item.OutputPath = outputPath + torrent.Name; + item.RemainingSize = torrent.Size - torrent.BytesDownloaded; + item.RemainingTime = TimeSpan.FromSeconds(torrent.Eta); + item.TotalSize = torrent.Size; + + if (torrent.State == DelugeTorrentStatus.Error) + { + item.Status = DownloadItemStatus.Failed; + } + else if (torrent.IsFinished && torrent.State != DelugeTorrentStatus.Checking) + { + item.Status = DownloadItemStatus.Completed; + } + else if (torrent.State == DelugeTorrentStatus.Queued) + { + item.Status = DownloadItemStatus.Queued; + } + else if (torrent.State == DelugeTorrentStatus.Paused) + { + item.Status = DownloadItemStatus.Paused; + } + else + { + item.Status = DownloadItemStatus.Downloading; + } + + // Here we detect if Deluge is managing the torrent and whether the seed criteria has been met. This allows drone to delete the torrent as appropriate. + if (torrent.IsAutoManaged && torrent.StopAtRatio && torrent.Ratio >= torrent.StopRatio && torrent.State == DelugeTorrentStatus.Paused) + { + item.IsReadOnly = false; + } + else + { + item.IsReadOnly = true; + } + + items.Add(item); + } + + return items; + } + + public override void RemoveItem(String hash) + { + _proxy.RemoveTorrent(hash.ToLower(), false, Settings); + } + + public override String RetryDownload(String hash) + { + throw new NotSupportedException(); + } + + public override DownloadClientStatus GetStatus() + { + var config = _proxy.GetConfig(Settings); + + var destDir = new OsPath(config.GetValueOrDefault("download_location") as string); + + if (config.GetValueOrDefault("move_completed", false).ToString() == "True") + { + destDir = new OsPath(config.GetValueOrDefault("move_completed_path") as string); + } + + var status = new DownloadClientStatus + { + IsLocalhost = Settings.Host == "127.0.0.1" || Settings.Host == "localhost" + }; + + if (!destDir.IsEmpty) + { + status.OutputRootFolders = new List { _remotePathMappingService.RemapRemoteToLocal(Settings.Host, destDir) }; + } + + return status; + } + + protected override void Test(List failures) + { + failures.AddIfNotNull(TestConnection()); + if (failures.Any()) return; + failures.AddIfNotNull(TestCategory()); + failures.AddIfNotNull(TestGetTorrents()); + } + + private ValidationFailure TestConnection() + { + try + { + _proxy.GetVersion(Settings); + } + catch (DownloadClientAuthenticationException ex) + { + _logger.ErrorException(ex.Message, ex); + return new NzbDroneValidationFailure("Password", "Authentication failed"); + } + catch (WebException ex) + { + _logger.ErrorException(ex.Message, ex); + if (ex.Status == WebExceptionStatus.ConnectFailure) + { + return new NzbDroneValidationFailure("Host", "Unable to connect") + { + DetailedDescription = "Please verify the hostname and port." + }; + } + else if (ex.Status == WebExceptionStatus.ConnectionClosed) + { + return new NzbDroneValidationFailure("UseSsl", "Verify SSL settings") + { + DetailedDescription = "Please verify your SSL configuration on both Deluge and NzbDrone." + }; + } + else if (ex.Status == WebExceptionStatus.SecureChannelFailure) + { + return new NzbDroneValidationFailure("UseSsl", "Unable to connect through SSL") + { + DetailedDescription = "Drone is unable to connect to Deluge using SSL. This problem could be computer related. Please try to configure both drone and Deluge to not use SSL." + }; + } + else + { + return new NzbDroneValidationFailure(String.Empty, "Unknown exception: " + ex.Message); + } + } + catch (Exception ex) + { + _logger.ErrorException(ex.Message, ex); + return new NzbDroneValidationFailure(String.Empty, "Unknown exception: " + ex.Message); + } + + return null; + } + + private ValidationFailure TestCategory() + { + if (Settings.TvCategory.IsNullOrWhiteSpace()) + { + return null; + } + + var enabledPlugins = _proxy.GetEnabledPlugins(Settings); + + if (!enabledPlugins.Contains("Label")) + { + return new NzbDroneValidationFailure("TvCategory", "Label plugin not activated") + { + DetailedDescription = "You must have the Label plugin enabled in Deluge to use categories." + }; + } + + var labels = _proxy.GetAvailableLabels(Settings); + + if (!labels.Contains(Settings.TvCategory)) + { + _proxy.AddLabel(Settings.TvCategory, Settings); + labels = _proxy.GetAvailableLabels(Settings); + + if (!labels.Contains(Settings.TvCategory)) + { + return new NzbDroneValidationFailure("TvCategory", "Configuration of label failed") + { + DetailedDescription = "NzbDrone as unable to add the label to Deluge." + }; + } + } + + return null; + } + + private ValidationFailure TestGetTorrents() + { + try + { + _proxy.GetTorrents(Settings); + } + catch (Exception ex) + { + _logger.ErrorException(ex.Message, ex); + return new NzbDroneValidationFailure(String.Empty, "Failed to get the list of torrents: " + ex.Message); + } + + return null; + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeError.cs b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeError.cs new file mode 100644 index 000000000..f39146269 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeError.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Download.Clients.Deluge +{ + public class DelugeError + { + public String Message { get; set; } + public Int32 Code { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeException.cs b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeException.cs new file mode 100644 index 000000000..f41d6ca11 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeException.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Download.Clients.Deluge +{ + public class DelugeException : DownloadClientException + { + public Int32 Code { get; set; } + + public DelugeException(String message, Int32 code) + :base (message + " (code " + code + ")") + { + Code = code; + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Deluge/DelugePriority.cs b/src/NzbDrone.Core/Download/Clients/Deluge/DelugePriority.cs new file mode 100644 index 000000000..e5cf2924c --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Deluge/DelugePriority.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.Download.Clients.Deluge +{ + public enum DelugePriority + { + Last = 0, + First = 1 + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeProxy.cs b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeProxy.cs new file mode 100644 index 000000000..e35525f08 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeProxy.cs @@ -0,0 +1,309 @@ +using System; +using System.Net; +using System.Linq; +using System.Collections.Generic; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Rest; +using NLog; +using RestSharp; +using Newtonsoft.Json.Linq; + +namespace NzbDrone.Core.Download.Clients.Deluge +{ + public interface IDelugeProxy + { + String GetVersion(DelugeSettings settings); + Dictionary GetConfig(DelugeSettings settings); + DelugeTorrent[] GetTorrents(DelugeSettings settings); + DelugeTorrent[] GetTorrentsByLabel(String label, DelugeSettings settings); + String[] GetAvailablePlugins(DelugeSettings settings); + String[] GetEnabledPlugins(DelugeSettings settings); + String[] GetAvailableLabels(DelugeSettings settings); + void SetLabel(String hash, String label, DelugeSettings settings); + void SetTorrentConfiguration(String hash, String key, Object value, DelugeSettings settings); + void SetTorrentSeedingConfiguration(String hash, TorrentSeedConfiguration seedConfiguration, DelugeSettings settings); + void AddLabel(String label, DelugeSettings settings); + String AddTorrentFromMagnet(String magnetLink, DelugeSettings settings); + String AddTorrentFromFile(String filename, Byte[] fileContent, DelugeSettings settings); + Boolean RemoveTorrent(String hash, Boolean removeData, DelugeSettings settings); + void MoveTorrentToTopInQueue(String hash, DelugeSettings settings); + } + + public class DelugeProxy : IDelugeProxy + { + private static readonly String[] requiredProperties = new String[] { "hash", "name", "state", "progress", "eta", "message", "is_finished", "save_path", "total_size", "total_done", "time_added", "active_time", "ratio", "is_auto_managed", "stop_at_ratio", "remove_at_ratio", "stop_ratio" }; + + private readonly Logger _logger; + + private string _authPassword; + private CookieContainer _authCookieContainer; + + private static Int32 _callId; + + public DelugeProxy(Logger logger) + { + _logger = logger; + } + + public String GetVersion(DelugeSettings settings) + { + var response = ProcessRequest(settings, "daemon.info"); + + return response.Result; + } + + public Dictionary GetConfig(DelugeSettings settings) + { + var response = ProcessRequest>(settings, "core.get_config"); + + return response.Result; + } + + public DelugeTorrent[] GetTorrents(DelugeSettings settings) + { + var filter = new Dictionary(); + + // TODO: get_torrents_status returns the files as well, which starts to cause deluge timeouts when you get enough season packs. + //var response = ProcessRequest>(settings, "core.get_torrents_status", filter, new String[0]); + var response = ProcessRequest(settings, "web.update_ui", requiredProperties, filter); + + return response.Result.Torrents.Values.ToArray(); + } + + public DelugeTorrent[] GetTorrentsByLabel(String label, DelugeSettings settings) + { + var filter = new Dictionary(); + filter.Add("label", label); + + + //var response = ProcessRequest>(settings, "core.get_torrents_status", filter, new String[0]); + var response = ProcessRequest(settings, "web.update_ui", requiredProperties, filter); + + return response.Result.Torrents.Values.ToArray(); + } + + public String AddTorrentFromMagnet(String magnetLink, DelugeSettings settings) + { + var response = ProcessRequest(settings, "core.add_torrent_magnet", magnetLink, new JObject()); + + return response.Result; + } + + public String AddTorrentFromFile(String filename, Byte[] fileContent, DelugeSettings settings) + { + var response = ProcessRequest(settings, "core.add_torrent_file", filename, Convert.ToBase64String(fileContent), new JObject()); + + return response.Result; + } + + public Boolean RemoveTorrent(String hashString, Boolean removeData, DelugeSettings settings) + { + var response = ProcessRequest(settings, "core.remove_torrent", hashString, removeData); + + return response.Result; + } + + public void MoveTorrentToTopInQueue(String hash, DelugeSettings settings) + { + ProcessRequest(settings, "core.queue_top", (Object)new String[] { hash }); + } + + public String[] GetAvailablePlugins(DelugeSettings settings) + { + var response = ProcessRequest(settings, "core.get_available_plugins"); + + return response.Result; + } + + public String[] GetEnabledPlugins(DelugeSettings settings) + { + var response = ProcessRequest(settings, "core.get_enabled_plugins"); + + return response.Result; + } + + public String[] GetAvailableLabels(DelugeSettings settings) + { + var response = ProcessRequest(settings, "label.get_labels"); + + return response.Result; + } + + public void SetTorrentConfiguration(String hash, String key, Object value, DelugeSettings settings) + { + var arguments = new Dictionary(); + arguments.Add(key, value); + + ProcessRequest(settings, "core.set_torrent_options", new String[] { hash }, arguments); + } + + public void SetTorrentSeedingConfiguration(String hash, TorrentSeedConfiguration seedConfiguration, DelugeSettings settings) + { + if (seedConfiguration.Ratio != null) + { + var ratioArguments = new Dictionary(); + ratioArguments.Add("stop_ratio", seedConfiguration.Ratio.Value); + + ProcessRequest(settings, "core.set_torrent_options", new String[]{hash}, ratioArguments); + } + } + + public void AddLabel(String label, DelugeSettings settings) + { + ProcessRequest(settings, "label.add", label); + } + + public void SetLabel(String hash, String label, DelugeSettings settings) + { + ProcessRequest(settings, "label.set_torrent", hash, label); + } + + protected DelugeResponse ProcessRequest(DelugeSettings settings, String action, params Object[] arguments) + { + var client = BuildClient(settings); + + DelugeResponse response; + + try + { + response = ProcessRequest(client, action, arguments); + } + catch (WebException ex) + { + if (ex.Status == WebExceptionStatus.Timeout) + { + _logger.Debug("Deluge timeout during request, daemon connection may have been broken. Attempting to reconnect."); + response = new DelugeResponse(); + response.Error = new DelugeError(); + response.Error.Code = 2; + } + else + { + throw; + } + } + + if (response.Error != null) + { + if (response.Error.Code == 1 || response.Error.Code == 2) + { + AuthenticateClient(client); + + response = ProcessRequest(client, action, arguments); + + if (response.Error == null) + { + return response; + } + + throw new DownloadClientAuthenticationException(response.Error.Message); + } + + throw new DelugeException(response.Error.Message, response.Error.Code); + } + + return response; + } + + private DelugeResponse ProcessRequest(IRestClient client, String action, Object[] arguments) + { + var request = new RestRequest(Method.POST); + request.Resource = "json"; + request.RequestFormat = DataFormat.Json; + request.AddHeader("Accept-Encoding", "gzip,deflate"); + + var data = new Dictionary(); + data.Add("id", GetCallId()); + data.Add("method", action); + + if (arguments != null) + { + data.Add("params", arguments); + } + + request.AddBody(data); + + _logger.Debug("Url: {0} Action: {1}", client.BuildUri(request), action); + var response = client.ExecuteAndValidate>(request); + + return response; + } + + private IRestClient BuildClient(DelugeSettings settings) + { + var protocol = settings.UseSsl ? "https" : "http"; + + var url = String.Format(@"{0}://{1}:{2}", + protocol, + settings.Host, + settings.Port); + + var restClient = RestClientFactory.BuildClient(url); + restClient.Timeout = 4000; + + if (_authPassword != settings.Password || _authCookieContainer == null) + { + _authPassword = settings.Password; + AuthenticateClient(restClient); + } + else + { + restClient.CookieContainer = _authCookieContainer; + } + + return restClient; + } + + private void AuthenticateClient(IRestClient restClient) + { + restClient.CookieContainer = new CookieContainer(); + + var result = ProcessRequest(restClient, "auth.login", new Object[] { _authPassword }); + + if (!result.Result) + { + _logger.Debug("Deluge authentication failed."); + throw new DownloadClientAuthenticationException("Failed to authenticate with Deluge."); + } + else + { + _logger.Debug("Deluge authentication succeeded."); + _authCookieContainer = restClient.CookieContainer; + } + + ConnectDaemon(restClient); + } + + private void ConnectDaemon(IRestClient restClient) + { + var resultConnected = ProcessRequest(restClient, "web.connected", new Object[0]); + + if (resultConnected.Result) + { + return; + } + + var resultHosts = ProcessRequest>(restClient, "web.get_hosts", new Object[0]); + + if (resultHosts.Result != null) + { + // The returned list contains the id, ip, port and status of each available connection. We want the 127.0.0.1 + var connection = resultHosts.Result.Where(v => "127.0.0.1" == (v[1] as String)).FirstOrDefault(); + + if (connection != null) + { + ProcessRequest(restClient, "web.connect", new Object[] { connection[0] }); + } + else + { + throw new DownloadClientException("Failed to connect to Deluge daemon."); + } + } + } + + private Int32 GetCallId() + { + return System.Threading.Interlocked.Increment(ref _callId); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeResponse.cs b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeResponse.cs new file mode 100644 index 000000000..18e0c62b5 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeResponse.cs @@ -0,0 +1,12 @@ +using System; +using Newtonsoft.Json.Linq; + +namespace NzbDrone.Core.Download.Clients.Deluge +{ + public class DelugeResponse + { + public Int32 Id { get; set; } + public TResult Result { get; set; } + public DelugeError Error { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeSettings.cs b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeSettings.cs new file mode 100644 index 000000000..ece3a9795 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeSettings.cs @@ -0,0 +1,58 @@ +using System; +using FluentValidation; +using FluentValidation.Results; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.Download.Clients.Deluge +{ + public class DelugeSettingsValidator : AbstractValidator + { + public DelugeSettingsValidator() + { + RuleFor(c => c.Host).NotEmpty(); + RuleFor(c => c.Port).GreaterThan(0); + + RuleFor(c => c.TvCategory).Matches("^[-a-z]*$").WithMessage("Allowed characters a-z and -"); + } + } + + public class DelugeSettings : IProviderConfig + { + private static readonly DelugeSettingsValidator validator = new DelugeSettingsValidator(); + + public DelugeSettings() + { + Host = "localhost"; + Port = 8112; + Password = "deluge"; + TvCategory = "tv-drone"; + } + + [FieldDefinition(0, Label = "Host", Type = FieldType.Textbox)] + public String Host { get; set; } + + [FieldDefinition(1, Label = "Port", Type = FieldType.Textbox)] + public Int32 Port { get; set; } + + [FieldDefinition(2, Label = "Password", Type = FieldType.Password)] + public String Password { get; set; } + + [FieldDefinition(3, Label = "Category", Type = FieldType.Textbox)] + public String TvCategory { get; set; } + + [FieldDefinition(4, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(DelugePriority), HelpText = "Priority to use when grabbing episodes that aired within the last 14 days")] + public Int32 RecentTvPriority { get; set; } + + [FieldDefinition(5, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(DelugePriority), HelpText = "Priority to use when grabbing episodes that aired over 14 days ago")] + public Int32 OlderTvPriority { get; set; } + + [FieldDefinition(6, Label = "Use SSL", Type = FieldType.Checkbox)] + public Boolean UseSsl { get; set; } + + public ValidationResult Validate() + { + return validator.Validate(this); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeTorrent.cs b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeTorrent.cs new file mode 100644 index 000000000..b8b998446 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeTorrent.cs @@ -0,0 +1,59 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Download.Clients.Deluge +{ + public class DelugeTorrent + { + public String Hash { get; set; } + public String Name { get; set; } + public String State { get; set; } + public Double Progress { get; set; } + public Double Eta { get; set; } + public String Message { get; set; } + + [JsonProperty(PropertyName = "is_finished")] + public Boolean IsFinished { get; set; } + + // Other paths: What is the difference between 'move_completed_path' and 'move_on_completed_path'? + /* + [JsonProperty(PropertyName = "move_completed_path")] + public String DownloadPathMoveCompleted { get; set; } + [JsonProperty(PropertyName = "move_on_completed_path")] + public String DownloadPathMoveOnCompleted { get; set; } + */ + + [JsonProperty(PropertyName = "save_path")] + public String DownloadPath { get; set; } + + [JsonProperty(PropertyName = "total_size")] + public Int64 Size { get; set; } + + [JsonProperty(PropertyName = "total_done")] + public Int64 BytesDownloaded { get; set; } + + [JsonProperty(PropertyName = "time_added")] + public Double DateAdded { get; set; } + + [JsonProperty(PropertyName = "active_time")] + public Int32 SecondsDownloading { get; set; } + + [JsonProperty(PropertyName = "ratio")] + public Double Ratio { get; set; } + + [JsonProperty(PropertyName = "is_auto_managed")] + public Boolean IsAutoManaged { get; set; } + + [JsonProperty(PropertyName = "stop_at_ratio")] + public Boolean StopAtRatio { get; set; } + + [JsonProperty(PropertyName = "remove_at_ratio")] + public Boolean RemoveAtRatio { get; set; } + + [JsonProperty(PropertyName = "stop_ratio")] + public Double StopRatio { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeTorrentStatus.cs b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeTorrentStatus.cs new file mode 100644 index 000000000..20e3c2e87 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeTorrentStatus.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Download.Clients.Deluge +{ + class DelugeTorrentStatus + { + public const String Paused = "Paused"; + public const String Queued = "Queued"; + public const String Downloading = "Downloading"; + public const String Seeding = "Seeding"; + public const String Checking = "Checking"; + public const String Error = "Error"; + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeUpdateUIResult.cs b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeUpdateUIResult.cs new file mode 100644 index 000000000..2fea430d1 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeUpdateUIResult.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Download.Clients.Deluge +{ + public class DelugeUpdateUIResult + { + public Dictionary Stats { get; set; } + public Boolean Connected { get; set; } + public Dictionary Torrents { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadClientAuthenticationException.cs b/src/NzbDrone.Core/Download/Clients/DownloadClientAuthenticationException.cs new file mode 100644 index 000000000..a06e30906 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadClientAuthenticationException.cs @@ -0,0 +1,31 @@ +using System; +using NzbDrone.Common.Exceptions; + +namespace NzbDrone.Core.Download.Clients +{ + public class DownloadClientAuthenticationException : DownloadClientException + { + public DownloadClientAuthenticationException(string message, params object[] args) + : base(message, args) + { + + } + + public DownloadClientAuthenticationException(string message) + : base(message) + { + + } + + public DownloadClientAuthenticationException(string message, Exception innerException, params object[] args) + : base(message, innerException, args) + { + + } + + public DownloadClientAuthenticationException(string message, Exception innerException) + : base(message, innerException) + { + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs index 75e57c55b..6330c417f 100644 --- a/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs @@ -131,7 +131,7 @@ namespace NzbDrone.Core.Download.Clients.Nzbget historyItem.DownloadClientId = droneParameter == null ? item.Id.ToString() : droneParameter.Value.ToString(); historyItem.Title = item.Name; historyItem.TotalSize = MakeInt64(item.FileSizeHi, item.FileSizeLo); - historyItem.OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, item.DestDir); + historyItem.OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(item.DestDir)); historyItem.Category = item.Category; historyItem.Message = String.Format("PAR Status: {0} - Unpack Status: {1} - Move Status: {2} - Script Status: {3} - Delete Status: {4} - Mark Status: {5}", item.ParStatus, item.UnpackStatus, item.MoveStatus, item.ScriptStatus, item.DeleteStatus, item.MarkStatus); historyItem.Status = DownloadItemStatus.Completed; @@ -215,7 +215,7 @@ namespace NzbDrone.Core.Download.Clients.Nzbget if (category != null) { - status.OutputRootFolders = new List { _remotePathMappingService.RemapRemoteToLocal(Settings.Host, category.DestDir) }; + status.OutputRootFolders = new List { _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(category.DestDir)) }; } return status; @@ -321,10 +321,10 @@ namespace NzbDrone.Core.Download.Clients.Nzbget if (category != null) { - var localPath = Settings.TvCategoryLocalPath; + var localPath = new OsPath(Settings.TvCategoryLocalPath); Settings.TvCategoryLocalPath = null; - _remotePathMappingService.MigrateLocalCategoryPath(Definition.Id, Settings, Settings.Host, category.DestDir, localPath); + _remotePathMappingService.MigrateLocalCategoryPath(Definition.Id, Settings, Settings.Host, new OsPath(category.DestDir), localPath); _logger.Info("Discovered Local Category Path for {0}, the setting was automatically moved to the Remote Path Mapping table.", Definition.Name); } diff --git a/src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs b/src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs index de8182094..11bd0fce1 100644 --- a/src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs +++ b/src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs @@ -89,7 +89,7 @@ namespace NzbDrone.Core.Download.Clients.Pneumatic TotalSize = _diskProvider.GetFileSize(file), - OutputPath = file + OutputPath = new OsPath(file) }; if (_diskProvider.IsFileLocked(file)) diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs index 150fe24cc..9b59132d4 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs @@ -156,20 +156,20 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd historyItem.Status = DownloadItemStatus.Downloading; } - var outputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, sabHistoryItem.Storage); + var outputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(sabHistoryItem.Storage)); - if (!outputPath.IsNullOrWhiteSpace()) + if (!outputPath.IsEmpty) { historyItem.OutputPath = outputPath; - var parent = outputPath.GetParentPath(); - while (parent != null) + var parent = outputPath.Directory; + while (!parent.IsEmpty) { - if (Path.GetFileName(parent) == sabHistoryItem.Title) + if (parent.FileName == sabHistoryItem.Title) { historyItem.OutputPath = parent; } - parent = parent.GetParentPath(); + parent = parent.Directory; } } @@ -259,52 +259,21 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd protected IEnumerable GetCategories(SabnzbdConfig config) { - var completeDir = config.Misc.complete_dir.TrimEnd('\\', '/'); + var completeDir = new OsPath(config.Misc.complete_dir); - if (!completeDir.StartsWith("/") && !completeDir.StartsWith("\\") && !completeDir.Contains(':')) + if (!completeDir.IsRooted) { var queue = _proxy.GetQueue(0, 1, Settings); + var defaultRootFolder = new OsPath(queue.DefaultRootFolder); - if (queue.DefaultRootFolder.StartsWith("/")) - { - completeDir = queue.DefaultRootFolder + "/" + completeDir; - } - else - { - completeDir = queue.DefaultRootFolder + "\\" + completeDir; - } + completeDir = defaultRootFolder + completeDir; } foreach (var category in config.Categories) { - var relativeDir = category.Dir.TrimEnd('*'); + var relativeDir = new OsPath(category.Dir.TrimEnd('*')); - if (relativeDir.IsNullOrWhiteSpace()) - { - category.FullPath = completeDir; - } - else if (completeDir.StartsWith("/")) - { // Process remote Linux paths irrespective of our own OS. - if (relativeDir.StartsWith("/")) - { - category.FullPath = relativeDir; - } - else - { - category.FullPath = completeDir + "/" + relativeDir; - } - } - else - { // Process remote Windows paths irrespective of our own OS. - if (relativeDir.StartsWith("\\") || relativeDir.Contains(':')) - { - category.FullPath = relativeDir; - } - else - { - category.FullPath = completeDir + "\\" + relativeDir; - } - } + category.FullPath = completeDir + relativeDir; yield return category; } @@ -329,7 +298,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd if (category != null) { - status.OutputRootFolders = new List { _remotePathMappingService.RemapRemoteToLocal(Settings.Host, category.FullPath) }; + status.OutputRootFolders = new List { _remotePathMappingService.RemapRemoteToLocal(Settings.Host, category.FullPath) }; } return status; @@ -454,7 +423,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd if (category != null) { - var localPath = Settings.TvCategoryLocalPath; + var localPath = new OsPath(Settings.TvCategoryLocalPath); Settings.TvCategoryLocalPath = null; _remotePathMappingService.MigrateLocalCategoryPath(Definition.Id, Settings, Settings.Host, category.FullPath, localPath); diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdCategory.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdCategory.cs index 2068cd0a8..18f72d271 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdCategory.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdCategory.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Text; +using NzbDrone.Common.Disk; namespace NzbDrone.Core.Download.Clients.Sabnzbd { @@ -30,6 +31,6 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd public String Script { get; set; } public String Dir { get; set; } - public String FullPath { get; set; } + public OsPath FullPath { get; set; } } } diff --git a/src/NzbDrone.Core/Download/Clients/TorrentBlackhole/TorrentBlackhole.cs b/src/NzbDrone.Core/Download/Clients/TorrentBlackhole/TorrentBlackhole.cs new file mode 100644 index 000000000..cb011f82f --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/TorrentBlackhole/TorrentBlackhole.cs @@ -0,0 +1,153 @@ +using System; +using System.IO; +using System.Linq; +using System.Collections.Generic; +using NzbDrone.Common; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.MediaFiles; +using NLog; +using Omu.ValueInjecter; +using FluentValidation.Results; +using NzbDrone.Core.RemotePathMappings; + +namespace NzbDrone.Core.Download.Clients.TorrentBlackhole +{ + public class TorrentBlackhole : DownloadClientBase + { + private readonly IDiskScanService _diskScanService; + private readonly IHttpClient _httpClient; + + public TorrentBlackhole(IDiskScanService diskScanService, + IHttpClient httpClient, + IConfigService configService, + IDiskProvider diskProvider, + IParsingService parsingService, + IRemotePathMappingService remotePathMappingService, + Logger logger) + : base(configService, diskProvider, remotePathMappingService, logger) + { + _diskScanService = diskScanService; + _httpClient = httpClient; + } + + public override DownloadProtocol Protocol + { + get + { + return DownloadProtocol.Torrent; + } + } + + public override string Download(RemoteEpisode remoteEpisode) + { + var url = remoteEpisode.Release.DownloadUrl; + var title = remoteEpisode.Release.Title; + + title = FileNameBuilder.CleanFileName(title); + + var filename = Path.Combine(Settings.TorrentFolder, String.Format("{0}.torrent", title)); + + _logger.Debug("Downloading torrent from: {0} to: {1}", url, filename); + _httpClient.DownloadFile(url, filename); + _logger.Debug("Torrent Download succeeded, saved to: {0}", filename); + + return null; + } + + public override IEnumerable GetItems() + { + foreach (var folder in _diskProvider.GetDirectories(Settings.WatchFolder)) + { + var title = FileNameBuilder.CleanFileName(Path.GetFileName(folder)); + + var files = _diskProvider.GetFiles(folder, SearchOption.AllDirectories); + + var historyItem = new DownloadClientItem + { + DownloadClient = Definition.Name, + DownloadClientId = Definition.Name + "_" + Path.GetFileName(folder) + "_" + _diskProvider.FolderGetCreationTime(folder).Ticks, + Category = "nzbdrone", + Title = title, + + TotalSize = files.Select(_diskProvider.GetFileSize).Sum(), + + OutputPath = new OsPath(folder) + }; + + if (files.Any(_diskProvider.IsFileLocked)) + { + historyItem.Status = DownloadItemStatus.Downloading; + } + else + { + historyItem.Status = DownloadItemStatus.Completed; + + historyItem.RemainingTime = TimeSpan.Zero; + } + + yield return historyItem; + } + + foreach (var videoFile in _diskScanService.GetVideoFiles(Settings.WatchFolder, false)) + { + var title = FileNameBuilder.CleanFileName(Path.GetFileName(videoFile)); + + var historyItem = new DownloadClientItem + { + DownloadClient = Definition.Name, + DownloadClientId = Definition.Name + "_" + Path.GetFileName(videoFile) + "_" + _diskProvider.FileGetLastWrite(videoFile).Ticks, + Category = "nzbdrone", + Title = title, + + TotalSize = _diskProvider.GetFileSize(videoFile), + + OutputPath = new OsPath(videoFile) + }; + + if (_diskProvider.IsFileLocked(videoFile)) + { + historyItem.Status = DownloadItemStatus.Downloading; + } + else + { + historyItem.Status = DownloadItemStatus.Completed; + historyItem.RemainingTime = TimeSpan.Zero; + } + + yield return historyItem; + } + } + + public override void RemoveItem(string id) + { + throw new NotSupportedException(); + } + + public override String RetryDownload(string id) + { + throw new NotSupportedException(); + } + + public override DownloadClientStatus GetStatus() + { + return new DownloadClientStatus + { + IsLocalhost = true, + OutputRootFolders = new List { new OsPath(Settings.WatchFolder) } + }; + } + + protected override void Test(List failures) + { + failures.AddIfNotNull(TestFolder(Settings.TorrentFolder, "TorrentFolder")); + failures.AddIfNotNull(TestFolder(Settings.WatchFolder, "WatchFolder")); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/TorrentBlackhole/TorrentBlackholeSettings.cs b/src/NzbDrone.Core/Download/Clients/TorrentBlackhole/TorrentBlackholeSettings.cs new file mode 100644 index 000000000..fae041c40 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/TorrentBlackhole/TorrentBlackholeSettings.cs @@ -0,0 +1,37 @@ +using FluentValidation; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using FluentValidation.Results; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation.Paths; + +namespace NzbDrone.Core.Download.Clients.TorrentBlackhole +{ + public class TorrentBlackholeSettingsValidator : AbstractValidator + { + public TorrentBlackholeSettingsValidator() + { + //Todo: Validate that the path actually exists + RuleFor(c => c.TorrentFolder).IsValidPath(); + } + } + + public class TorrentBlackholeSettings : IProviderConfig + { + private static readonly TorrentBlackholeSettingsValidator validator = new TorrentBlackholeSettingsValidator(); + + [FieldDefinition(0, Label = "Torrent Folder", Type = FieldType.Path)] + public String TorrentFolder { get; set; } + + [FieldDefinition(1, Label = "Watch Folder", Type = FieldType.Path)] + public String WatchFolder { get; set; } + + public ValidationResult Validate() + { + return validator.Validate(this); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/TorrentSeedConfiguration.cs b/src/NzbDrone.Core/Download/Clients/TorrentSeedConfiguration.cs new file mode 100644 index 000000000..ec6572957 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/TorrentSeedConfiguration.cs @@ -0,0 +1,12 @@ +using System; + +namespace NzbDrone.Core.Download.Clients +{ + public class TorrentSeedConfiguration + { + public static TorrentSeedConfiguration DefaultConfiguration = new TorrentSeedConfiguration(); + + public Double? Ratio { get; set; } + public TimeSpan? SeedTime { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/Transmission.cs b/src/NzbDrone.Core/Download/Clients/Transmission/Transmission.cs new file mode 100644 index 000000000..8f9108b2e --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Transmission/Transmission.cs @@ -0,0 +1,245 @@ +using System; +using System.IO; +using System.Linq; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using NzbDrone.Common; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Http; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Configuration; +using NLog; +using FluentValidation.Results; +using NzbDrone.Core.MediaFiles.TorrentInfo; +using NzbDrone.Core.Validation; +using System.Net; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.RemotePathMappings; + +namespace NzbDrone.Core.Download.Clients.Transmission +{ + public class Transmission : TorrentClientBase + { + private readonly ITransmissionProxy _proxy; + + public Transmission(ITransmissionProxy proxy, + ITorrentFileInfoReader torrentFileInfoReader, + IHttpClient httpClient, + IConfigService configService, + IDiskProvider diskProvider, + IParsingService parsingService, + IRemotePathMappingService remotePathMappingService, + Logger logger) + : base(torrentFileInfoReader, httpClient, configService, diskProvider, parsingService, remotePathMappingService, logger) + { + _proxy = proxy; + } + + + protected override String AddFromMagnetLink(RemoteEpisode remoteEpisode, String hash, String magnetLink) + { + _proxy.AddTorrentFromUrl(magnetLink, GetDownloadDirectory(), Settings); + + var isRecentEpisode = remoteEpisode.IsRecentEpisode(); + + if (isRecentEpisode && Settings.RecentTvPriority == (int)TransmissionPriority.First || + !isRecentEpisode && Settings.OlderTvPriority == (int)TransmissionPriority.First) + { + _proxy.MoveTorrentToTopInQueue(hash, Settings); + } + + return hash; + } + + protected override String AddFromTorrentFile(RemoteEpisode remoteEpisode, String hash, String filename, Byte[] fileContent) + { + _proxy.AddTorrentFromData(fileContent, GetDownloadDirectory(), Settings); + + var isRecentEpisode = remoteEpisode.IsRecentEpisode(); + + if (isRecentEpisode && Settings.RecentTvPriority == (int)TransmissionPriority.First || + !isRecentEpisode && Settings.OlderTvPriority == (int)TransmissionPriority.First) + { + _proxy.MoveTorrentToTopInQueue(hash, Settings); + } + + return hash; + } + + private String GetDownloadDirectory() + { + if (Settings.TvCategory.IsNullOrWhiteSpace()) return null; + + var config = _proxy.GetConfig(Settings); + var destDir = (String)config.GetValueOrDefault("download-dir"); + + return String.Format("{0}/.{1}", destDir, Settings.TvCategory); + } + + public override IEnumerable GetItems() + { + List torrents; + + try + { + torrents = _proxy.GetTorrents(Settings); + } + catch (DownloadClientException ex) + { + _logger.ErrorException(ex.Message, ex); + return Enumerable.Empty(); + } + + var items = new List(); + + foreach (var torrent in torrents) + { + var outputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(torrent.DownloadDir)); + + if (Settings.TvCategory.IsNotNullOrWhiteSpace()) + { + var directories = outputPath.FullPath.Split('\\', '/'); + if (!directories.Contains(String.Format(".{0}", Settings.TvCategory))) continue; + } + + var item = new DownloadClientItem(); + item.DownloadClientId = torrent.HashString.ToUpper(); + item.Category = Settings.TvCategory; + item.Title = torrent.Name; + + item.DownloadClient = Definition.Name; + item.DownloadTime = TimeSpan.FromSeconds(torrent.SecondsDownloading); + item.Message = torrent.ErrorString; + + item.OutputPath = outputPath + torrent.Name; + item.RemainingSize = torrent.LeftUntilDone; + item.RemainingTime = TimeSpan.FromSeconds(torrent.Eta); + item.TotalSize = torrent.TotalSize; + + if (!torrent.ErrorString.IsNullOrWhiteSpace()) + { + item.Status = DownloadItemStatus.Failed; + } + else if (torrent.Status == TransmissionTorrentStatus.Seeding || torrent.Status == TransmissionTorrentStatus.SeedingWait) + { + item.Status = DownloadItemStatus.Completed; + } + else if (torrent.IsFinished && torrent.Status != TransmissionTorrentStatus.Check && torrent.Status != TransmissionTorrentStatus.CheckWait) + { + item.Status = DownloadItemStatus.Completed; + } + else if (torrent.Status == TransmissionTorrentStatus.Queued) + { + item.Status = DownloadItemStatus.Queued; + } + else + { + item.Status = DownloadItemStatus.Downloading; + } + + item.IsReadOnly = torrent.Status != TransmissionTorrentStatus.Stopped; + + items.Add(item); + } + + return items; + } + + public override void RemoveItem(String hash) + { + _proxy.RemoveTorrent(hash.ToLower(), false, Settings); + } + + public override String RetryDownload(String hash) + { + throw new NotSupportedException(); + } + + public override DownloadClientStatus GetStatus() + { + var config = _proxy.GetConfig(Settings); + var destDir = config.GetValueOrDefault("download-dir") as string; + + if (Settings.TvCategory.IsNotNullOrWhiteSpace()) + { + destDir = String.Format("{0}/.{1}", destDir, Settings.TvCategory); + } + + return new DownloadClientStatus + { + IsLocalhost = Settings.Host == "127.0.0.1" || Settings.Host == "localhost", + OutputRootFolders = new List { _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(destDir)) } + }; + } + + protected override void Test(List failures) + { + failures.AddIfNotNull(TestConnection()); + if (failures.Any()) return; + failures.AddIfNotNull(TestGetTorrents()); + } + + private ValidationFailure TestConnection() + { + try + { + var versionString = _proxy.GetVersion(Settings); + + _logger.Debug("Transmission version information: {0}", versionString); + + var versionResult = Regex.Match(versionString, @"(? GetTorrents(TransmissionSettings settings); + void AddTorrentFromUrl(String torrentUrl, String downloadDirectory, TransmissionSettings settings); + void AddTorrentFromData(Byte[] torrentData, String downloadDirectory, TransmissionSettings settings); + void SetTorrentSeedingConfiguration(String hash, TorrentSeedConfiguration seedConfiguration, TransmissionSettings settings); + Dictionary GetConfig(TransmissionSettings settings); + String GetVersion(TransmissionSettings settings); + void RemoveTorrent(String hash, Boolean removeData, TransmissionSettings settings); + void MoveTorrentToTopInQueue(String hashString, TransmissionSettings settings); + } + + public class TransmissionProxy: ITransmissionProxy + { + private readonly Logger _logger; + private String _sessionId; + + public TransmissionProxy(Logger logger) + { + _logger = logger; + } + + public List GetTorrents(TransmissionSettings settings) + { + var result = GetTorrentStatus(settings); + + var torrents = ((JArray)result.Arguments["torrents"]).ToObject>(); + + return torrents; + } + + public void AddTorrentFromUrl(String torrentUrl, String downloadDirectory, TransmissionSettings settings) + { + var arguments = new Dictionary(); + arguments.Add("filename", torrentUrl); + + if (!downloadDirectory.IsNullOrWhiteSpace()) + { + arguments.Add("download-dir", downloadDirectory); + } + + ProcessRequest("torrent-add", arguments, settings); + } + + public void AddTorrentFromData(Byte[] torrentData, String downloadDirectory, TransmissionSettings settings) + { + var arguments = new Dictionary(); + arguments.Add("metainfo", Convert.ToBase64String(torrentData)); + + if (!downloadDirectory.IsNullOrWhiteSpace()) + { + arguments.Add("download-dir", downloadDirectory); + } + + ProcessRequest("torrent-add", arguments, settings); + } + + public void SetTorrentSeedingConfiguration(String hash, TorrentSeedConfiguration seedConfiguration, TransmissionSettings settings) + { + var arguments = new Dictionary(); + arguments.Add("ids", new String[] { hash }); + + if (seedConfiguration.Ratio != null) + { + arguments.Add("seedRatioLimit", seedConfiguration.Ratio.Value); + arguments.Add("seedRatioMode", 1); + } + + if (seedConfiguration.SeedTime != null) + { + arguments.Add("seedIdleLimit", Convert.ToInt32(seedConfiguration.SeedTime.Value.TotalMinutes)); + arguments.Add("seedIdleMode", 1); + } + + ProcessRequest("torrent-set", arguments, settings); + } + + public String GetVersion(TransmissionSettings settings) + { + // Gets the transmission version. + var config = GetConfig(settings); + + var version = config["version"]; + + return version.ToString(); + } + + public Dictionary GetConfig(TransmissionSettings settings) + { + // Gets the transmission version. + var result = GetSessionVariables(settings); + + return result.Arguments; + } + + public void RemoveTorrent(String hashString, Boolean removeData, TransmissionSettings settings) + { + var arguments = new Dictionary(); + arguments.Add("ids", new String[] { hashString }); + arguments.Add("delete-local-data", removeData); + + ProcessRequest("torrent-remove", arguments, settings); + } + + public void MoveTorrentToTopInQueue(String hashString, TransmissionSettings settings) + { + var arguments = new Dictionary(); + arguments.Add("ids", new String[] { hashString }); + + ProcessRequest("queue-move-top", arguments, settings); + } + + private TransmissionResponse GetSessionVariables(TransmissionSettings settings) + { + // Retrieve transmission information such as the default download directory, bandwith throttling and seed ratio. + + return ProcessRequest("session-get", null, settings); + } + + private TransmissionResponse GetSessionStatistics(TransmissionSettings settings) + { + return ProcessRequest("session-stats", null, settings); + } + + private TransmissionResponse GetTorrentStatus(TransmissionSettings settings) + { + return GetTorrentStatus(null, settings); + } + + private TransmissionResponse GetTorrentStatus(IEnumerable hashStrings, TransmissionSettings settings) + { + var fields = new String[]{ + "id", + "hashString", // Unique torrent ID. Use this instead of the client id? + "name", + "downloadDir", + "status", + "totalSize", + "leftUntilDone", + "isFinished", + "eta", + "errorString" + }; + + var arguments = new Dictionary(); + arguments.Add("fields", fields); + + if (hashStrings != null) + { + arguments.Add("ids", hashStrings); + } + + var result = ProcessRequest("torrent-get", arguments, settings); + + return result; + } + + protected String GetSessionId(IRestClient client, TransmissionSettings settings) + { + var request = new RestRequest(); + request.RequestFormat = DataFormat.Json; + + _logger.Debug("Url: {0} GetSessionId", client.BuildUri(request)); + var restResponse = client.Execute(request); + + if (restResponse.StatusCode == HttpStatusCode.MovedPermanently) + { + var uri = new Uri(restResponse.ResponseUri, (String)restResponse.GetHeaderValue("Location")); + + throw new DownloadClientException("Remote site redirected to " + uri); + } + + // We expect the StatusCode = Conflict, coz that will provide us with a new session id. + if (restResponse.StatusCode == HttpStatusCode.Conflict) + { + var sessionId = restResponse.Headers.SingleOrDefault(o => o.Name == "X-Transmission-Session-Id"); + + if (sessionId == null) + { + throw new DownloadClientException("Remote host did not return a Session Id."); + } + + return (String)sessionId.Value; + } + else if (restResponse.StatusCode == HttpStatusCode.Unauthorized) + { + throw new DownloadClientAuthenticationException("User authentication failed."); + } + + restResponse.ValidateResponse(client); + + throw new DownloadClientException("Remote host did not return a Session Id."); + } + + public TransmissionResponse ProcessRequest(String action, Object arguments, TransmissionSettings settings) + { + var client = BuildClient(settings); + + if (String.IsNullOrWhiteSpace(_sessionId)) + { + _sessionId = GetSessionId(client, settings); + } + + var request = new RestRequest(Method.POST); + request.RequestFormat = DataFormat.Json; + request.AddHeader("X-Transmission-Session-Id", _sessionId); + + var data = new Dictionary(); + data.Add("method", action); + + if (arguments != null) + { + data.Add("arguments", arguments); + } + + request.AddBody(data); + + _logger.Debug("Url: {0} Action: {1}", client.BuildUri(request), action); + var restResponse = client.Execute(request); + + if (restResponse.StatusCode == HttpStatusCode.Conflict) + { + _sessionId = GetSessionId(client, settings); + request.Parameters.First(o => o.Name == "X-Transmission-Session-Id").Value = _sessionId; + restResponse = client.Execute(request); + } + else if (restResponse.StatusCode == HttpStatusCode.Unauthorized) + { + throw new DownloadClientAuthenticationException("User authentication failed."); + } + + var transmissionResponse = restResponse.Read(client); + + if (transmissionResponse == null) + { + throw new TransmissionException("Unexpected response"); + } + else if (transmissionResponse.Result != "success") + { + throw new TransmissionException(transmissionResponse.Result); + } + + return transmissionResponse; + } + + private IRestClient BuildClient(TransmissionSettings settings) + { + var protocol = settings.UseSsl ? "https" : "http"; + + String url; + if (!settings.UrlBase.IsNullOrWhiteSpace()) + { + url = String.Format(@"{0}://{1}:{2}/{3}/transmission/rpc", protocol, settings.Host, settings.Port, settings.UrlBase.Trim('/')); + } + else + { + url = String.Format(@"{0}://{1}:{2}/transmission/rpc", protocol, settings.Host, settings.Port); + } + + var restClient = RestClientFactory.BuildClient(url); + restClient.FollowRedirects = false; + + if (!settings.Username.IsNullOrWhiteSpace()) + { + restClient.Authenticator = new HttpBasicAuthenticator(settings.Username, settings.Password); + } + + return restClient; + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionResponse.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionResponse.cs new file mode 100644 index 000000000..124ac97f3 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionResponse.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; + +namespace NzbDrone.Core.Download.Clients.Transmission +{ + public class TransmissionResponse + { + public String Result { get; set; } + public Dictionary Arguments { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionSettings.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionSettings.cs new file mode 100644 index 000000000..158f7e867 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionSettings.cs @@ -0,0 +1,62 @@ +using System; +using FluentValidation; +using FluentValidation.Results; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.Download.Clients.Transmission +{ + public class TransmissionSettingsValidator : AbstractValidator + { + public TransmissionSettingsValidator() + { + RuleFor(c => c.Host).NotEmpty(); + RuleFor(c => c.Port).GreaterThan(0); + + RuleFor(c => c.TvCategory).Matches("^[-a-z]*$").WithMessage("Allowed characters a-z and -"); + } + } + + public class TransmissionSettings : IProviderConfig + { + private static readonly TransmissionSettingsValidator validator = new TransmissionSettingsValidator(); + + public TransmissionSettings() + { + Host = "localhost"; + Port = 9091; + } + + [FieldDefinition(0, Label = "Host", Type = FieldType.Textbox)] + public String Host { get; set; } + + [FieldDefinition(1, Label = "Port", Type = FieldType.Textbox)] + public Int32 Port { get; set; } + + [FieldDefinition(2, Label = "Url Base", Type = FieldType.Textbox, Advanced = true, HelpText = "Adds a prefix to the transmission rpc url, see http://[host]:[port]/[urlBase]/transmission/rpc")] + public String UrlBase { get; set; } + + [FieldDefinition(3, Label = "Username", Type = FieldType.Textbox)] + public String Username { get; set; } + + [FieldDefinition(4, Label = "Password", Type = FieldType.Password)] + public String Password { get; set; } + + [FieldDefinition(5, Label = "Category", Type = FieldType.Textbox)] + public String TvCategory { get; set; } + + [FieldDefinition(6, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(TransmissionPriority), HelpText = "Priority to use when grabbing episodes that aired within the last 14 days")] + public Int32 RecentTvPriority { get; set; } + + [FieldDefinition(7, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(TransmissionPriority), HelpText = "Priority to use when grabbing episodes that aired over 14 days ago")] + public Int32 OlderTvPriority { get; set; } + + [FieldDefinition(8, Label = "Use SSL", Type = FieldType.Checkbox)] + public Boolean UseSsl { get; set; } + + public ValidationResult Validate() + { + return validator.Validate(this); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionTorrent.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionTorrent.cs new file mode 100644 index 000000000..c878a8d4e --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionTorrent.cs @@ -0,0 +1,31 @@ +using System; +using System.Linq; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.Transmission +{ + public class TransmissionTorrent + { + public Int32 Id { get; set; } + + public String HashString { get; set; } + + public String Name { get; set; } + + public String DownloadDir { get; set; } + + public Int64 TotalSize { get; set; } + + public Int64 LeftUntilDone { get; set; } + + public Boolean IsFinished { get; set; } + + public Int32 Eta { get; set; } + + public TransmissionTorrentStatus Status { get; set; } + + public Int32 SecondsDownloading { get; set; } + + public String ErrorString { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionTorrentStatus.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionTorrentStatus.cs new file mode 100644 index 000000000..662e3f3b0 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionTorrentStatus.cs @@ -0,0 +1,15 @@ +using System; + +namespace NzbDrone.Core.Download.Clients.Transmission +{ + public enum TransmissionTorrentStatus + { + Stopped = 0, + CheckWait = 1, + Check = 2, + Queued = 3, + Downloading = 4, + SeedingWait = 5, + Seeding = 6 + } +} diff --git a/src/NzbDrone.Core/Download/Clients/UsenetBlackhole/UsenetBlackhole.cs b/src/NzbDrone.Core/Download/Clients/UsenetBlackhole/UsenetBlackhole.cs index 59a96c356..333c2598c 100644 --- a/src/NzbDrone.Core/Download/Clients/UsenetBlackhole/UsenetBlackhole.cs +++ b/src/NzbDrone.Core/Download/Clients/UsenetBlackhole/UsenetBlackhole.cs @@ -70,11 +70,12 @@ namespace NzbDrone.Core.Download.Clients.UsenetBlackhole { DownloadClient = Definition.Name, DownloadClientId = Definition.Name + "_" + Path.GetFileName(folder) + "_" + _diskProvider.FolderGetCreationTime(folder).Ticks, + Category = "nzbdrone", Title = title, TotalSize = files.Select(_diskProvider.GetFileSize).Sum(), - OutputPath = folder + OutputPath = new OsPath(folder) }; if (files.Any(_diskProvider.IsFileLocked)) @@ -99,11 +100,12 @@ namespace NzbDrone.Core.Download.Clients.UsenetBlackhole { DownloadClient = Definition.Name, DownloadClientId = Definition.Name + "_" + Path.GetFileName(videoFile) + "_" + _diskProvider.FileGetLastWrite(videoFile).Ticks, + Category = "nzbdrone", Title = title, TotalSize = _diskProvider.GetFileSize(videoFile), - OutputPath = videoFile + OutputPath = new OsPath(videoFile) }; if (_diskProvider.IsFileLocked(videoFile)) @@ -135,7 +137,7 @@ namespace NzbDrone.Core.Download.Clients.UsenetBlackhole return new DownloadClientStatus { IsLocalhost = true, - OutputRootFolders = new List { Settings.WatchFolder } + OutputRootFolders = new List { new OsPath(Settings.WatchFolder) } }; } diff --git a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrent.cs b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrent.cs new file mode 100644 index 000000000..61cb1c147 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrent.cs @@ -0,0 +1,256 @@ +using System; +using System.IO; +using System.Linq; +using System.Collections.Generic; +using NzbDrone.Common; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.MediaFiles.TorrentInfo; +using NzbDrone.Core.Parser; +using NLog; +using NzbDrone.Core.Validation; +using FluentValidation.Results; +using System.Net; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.RemotePathMappings; +using NzbDrone.Common.EnvironmentInfo; + +namespace NzbDrone.Core.Download.Clients.UTorrent +{ + public class UTorrent : TorrentClientBase + { + private readonly IUTorrentProxy _proxy; + + public UTorrent(IUTorrentProxy proxy, + ITorrentFileInfoReader torrentFileInfoReader, + IHttpClient httpClient, + IConfigService configService, + IDiskProvider diskProvider, + IParsingService parsingService, + IRemotePathMappingService remotePathMappingService, + Logger logger) + : base(torrentFileInfoReader, httpClient, configService, diskProvider, parsingService, remotePathMappingService, logger) + { + _proxy = proxy; + } + + protected override String AddFromMagnetLink(RemoteEpisode remoteEpisode, String hash, String magnetLink) + { + _proxy.AddTorrentFromUrl(magnetLink, Settings); + _proxy.SetTorrentLabel(hash, Settings.TvCategory, Settings); + + var isRecentEpisode = remoteEpisode.IsRecentEpisode(); + + if (isRecentEpisode && Settings.RecentTvPriority == (int)UTorrentPriority.First || + !isRecentEpisode && Settings.OlderTvPriority == (int)UTorrentPriority.First) + { + _proxy.MoveTorrentToTopInQueue(hash, Settings); + } + + return hash; + } + + protected override String AddFromTorrentFile(RemoteEpisode remoteEpisode, String hash, String filename, Byte[] fileContent) + { + _proxy.AddTorrentFromFile(filename, fileContent, Settings); + _proxy.SetTorrentLabel(hash, Settings.TvCategory, Settings); + + var isRecentEpisode = remoteEpisode.IsRecentEpisode(); + + if (isRecentEpisode && Settings.RecentTvPriority == (int)UTorrentPriority.First || + !isRecentEpisode && Settings.OlderTvPriority == (int)UTorrentPriority.First) + { + _proxy.MoveTorrentToTopInQueue(hash, Settings); + } + + return hash; + } + + public override IEnumerable GetItems() + { + List torrents; + + try + { + torrents = _proxy.GetTorrents(Settings); + } + catch (DownloadClientException ex) + { + _logger.ErrorException(ex.Message, ex); + return Enumerable.Empty(); + } + + var queueItems = new List(); + + foreach (var torrent in torrents) + { + if (torrent.Label != Settings.TvCategory) + { + continue; + } + + var item = new DownloadClientItem(); + item.DownloadClientId = torrent.Hash; + item.Title = torrent.Name; + item.TotalSize = torrent.Size; + item.Category = torrent.Label; + item.DownloadClient = Definition.Name; + item.RemainingSize = torrent.Remaining; + if (torrent.Eta != -1) + { + item.RemainingTime = TimeSpan.FromSeconds(torrent.Eta); + } + + var outputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(torrent.RootDownloadPath)); + + if (outputPath == null || outputPath.FileName == torrent.Name) + { + item.OutputPath = outputPath; + } + else + { + item.OutputPath = outputPath + torrent.Name; + } + + if (torrent.Status.HasFlag(UTorrentTorrentStatus.Error)) + { + item.Status = DownloadItemStatus.Failed; + } + else if (torrent.Status.HasFlag(UTorrentTorrentStatus.Loaded) && + torrent.Status.HasFlag(UTorrentTorrentStatus.Checked) && torrent.Remaining == 0 && torrent.Progress == 1.0) + { + item.Status = DownloadItemStatus.Completed; + } + else if (torrent.Status.HasFlag(UTorrentTorrentStatus.Paused)) + { + item.Status = DownloadItemStatus.Paused; + } + else if (torrent.Status.HasFlag(UTorrentTorrentStatus.Started)) + { + item.Status = DownloadItemStatus.Downloading; + } + else + { + item.Status = DownloadItemStatus.Queued; + } + + // 'Started' without 'Queued' is when the torrent is 'forced seeding' + item.IsReadOnly = torrent.Status.HasFlag(UTorrentTorrentStatus.Queued) || torrent.Status.HasFlag(UTorrentTorrentStatus.Started); + + queueItems.Add(item); + } + + return queueItems; + } + + public override void RemoveItem(String id) + { + _proxy.RemoveTorrent(id, false, Settings); + } + + public override String RetryDownload(String id) + { + throw new NotSupportedException(); + } + + public override DownloadClientStatus GetStatus() + { + var config = _proxy.GetConfig(Settings); + + OsPath destDir = new OsPath(null); + + if (config.GetValueOrDefault("dir_active_download_flag") == "true") + { + destDir = new OsPath(config.GetValueOrDefault("dir_active_download")); + } + + if (config.GetValueOrDefault("dir_completed_download_flag") == "true") + { + destDir = new OsPath(config.GetValueOrDefault("dir_completed_download")); + + if (config.GetValueOrDefault("dir_add_label") == "true") + { + destDir = destDir + Settings.TvCategory; + } + } + + var status = new DownloadClientStatus + { + IsLocalhost = Settings.Host == "127.0.0.1" || Settings.Host == "localhost" + }; + + if (!destDir.IsEmpty) + { + status.OutputRootFolders = new List { _remotePathMappingService.RemapRemoteToLocal(Settings.Host, destDir) }; + } + + return status; + } + + protected override void Test(List failures) + { + failures.AddIfNotNull(TestConnection()); + if (failures.Any()) return; + failures.AddIfNotNull(TestGetTorrents()); + } + + private ValidationFailure TestConnection() + { + try + { + var version = _proxy.GetVersion(Settings); + + if (version < 25406) + { + return new ValidationFailure(string.Empty, "Old uTorrent client with unsupported API, need 3.0 or higher"); + } + } + catch (DownloadClientAuthenticationException ex) + { + _logger.ErrorException(ex.Message, ex); + return new NzbDroneValidationFailure("Username", "Authentication failure") + { + DetailedDescription = "Please verify your username and password." + }; + } + catch (WebException ex) + { + _logger.ErrorException(ex.Message, ex); + if (ex.Status == WebExceptionStatus.ConnectFailure) + { + return new NzbDroneValidationFailure("Host", "Unable to connect") + { + DetailedDescription = "Please verify the hostname and port." + }; + } + else + { + return new NzbDroneValidationFailure(String.Empty, "Unknown exception: " + ex.Message); + } + } + catch (Exception ex) + { + _logger.ErrorException(ex.Message, ex); + return new NzbDroneValidationFailure(String.Empty, "Unknown exception: " + ex.Message); + } + + return null; + } + + private ValidationFailure TestGetTorrents() + { + try + { + _proxy.GetTorrents(Settings); + } + catch (Exception ex) + { + _logger.ErrorException(ex.Message, ex); + return new NzbDroneValidationFailure(String.Empty, "Failed to get the list of torrents: " + ex.Message); + } + + return null; + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentPriority.cs b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentPriority.cs new file mode 100644 index 000000000..7173a71e4 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentPriority.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.Download.Clients.UTorrent +{ + public enum UTorrentPriority + { + Last = 0, + First = 1 + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentProxy.cs b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentProxy.cs new file mode 100644 index 000000000..93ff69886 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentProxy.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using NLog; +using NzbDrone.Common; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Rest; +using RestSharp; + +namespace NzbDrone.Core.Download.Clients.UTorrent +{ + public interface IUTorrentProxy + { + Int32 GetVersion(UTorrentSettings settings); + Dictionary GetConfig(UTorrentSettings settings); + List GetTorrents(UTorrentSettings settings); + + void AddTorrentFromUrl(String torrentUrl, UTorrentSettings settings); + void AddTorrentFromFile(String fileName, Byte[] fileContent, UTorrentSettings settings); + void SetTorrentSeedingConfiguration(String hash, TorrentSeedConfiguration seedConfiguration, UTorrentSettings settings); + + void RemoveTorrent(String hash, Boolean removeData, UTorrentSettings settings); + void SetTorrentLabel(String hash, String label, UTorrentSettings settings); + void MoveTorrentToTopInQueue(String hash, UTorrentSettings settings); + } + + public class UTorrentProxy : IUTorrentProxy + { + private readonly Logger _logger; + private readonly CookieContainer _cookieContainer; + private String _authToken; + + public UTorrentProxy(Logger logger) + { + _logger = logger; + _cookieContainer = new CookieContainer(); + } + + public Int32 GetVersion(UTorrentSettings settings) + { + var arguments = new Dictionary(); + arguments.Add("action", "getsettings"); + + var result = ProcessRequest(arguments, settings); + + return result.Build; + } + + public Dictionary GetConfig(UTorrentSettings settings) + { + var arguments = new Dictionary(); + arguments.Add("action", "getsettings"); + + var result = ProcessRequest(arguments, settings); + + var configuration = new Dictionary(); + + foreach (var configItem in result.Settings) + { + configuration.Add(configItem[0].ToString(), configItem[2].ToString()); + } + + return configuration; + } + + public List GetTorrents(UTorrentSettings settings) + { + var arguments = new Dictionary(); + arguments.Add("list", 1); + + var result = ProcessRequest(arguments, settings); + + return result.Torrents; + } + + public void AddTorrentFromUrl(String torrentUrl, UTorrentSettings settings) + { + var arguments = new Dictionary(); + arguments.Add("action", "add-url"); + arguments.Add("s", torrentUrl); + + ProcessRequest(arguments, settings); + } + + public void AddTorrentFromFile(String fileName, Byte[] fileContent, UTorrentSettings settings) + { + var arguments = new Dictionary(); + arguments.Add("action", "add-file"); + arguments.Add("path", String.Empty); + + var client = BuildClient(settings); + + // add-file should use POST unlike all other methods which are GET + var request = new RestRequest(Method.POST); + request.RequestFormat = DataFormat.Json; + request.Resource = "/gui/"; + request.AddParameter("token", _authToken, ParameterType.QueryString); + + foreach (var argument in arguments) + { + request.AddParameter(argument.Key, argument.Value, ParameterType.QueryString); + } + + request.AddFile("torrent_file", fileContent, fileName, @"application/octet-stream"); + + ProcessRequest(request, client); + } + + public void SetTorrentSeedingConfiguration(String hash, TorrentSeedConfiguration seedConfiguration, UTorrentSettings settings) + { + var arguments = new List>(); + arguments.Add("action", "setprops"); + arguments.Add("hash", hash); + + arguments.Add("s", "seed_override"); + arguments.Add("v", 1); + + if (seedConfiguration.Ratio != null) + { + arguments.Add("s","seed_ratio"); + arguments.Add("v", Convert.ToInt32(seedConfiguration.Ratio.Value * 1000)); + } + + if (seedConfiguration.SeedTime != null) + { + arguments.Add("s", "seed_time"); + arguments.Add("v", Convert.ToInt32(seedConfiguration.SeedTime.Value.TotalSeconds)); + } + + ProcessRequest(arguments, settings); + } + + public void RemoveTorrent(String hash, Boolean removeData, UTorrentSettings settings) + { + var arguments = new Dictionary(); + + if (removeData) + { + arguments.Add("action", "removedata"); + } + else + { + arguments.Add("action", "remove"); + } + + arguments.Add("hash", hash); + + ProcessRequest(arguments, settings); + } + + public void SetTorrentLabel(String hash, String label, UTorrentSettings settings) + { + var arguments = new Dictionary(); + arguments.Add("action", "setprops"); + arguments.Add("hash", hash); + + arguments.Add("s", "label"); + arguments.Add("v", label); + + ProcessRequest(arguments, settings); + } + + public void MoveTorrentToTopInQueue(String hash, UTorrentSettings settings) + { + var arguments = new Dictionary(); + arguments.Add("action", "queuetop"); + arguments.Add("hash", hash); + + ProcessRequest(arguments, settings); + } + + public UTorrentResponse ProcessRequest(IEnumerable> arguments, UTorrentSettings settings) + { + var client = BuildClient(settings); + + var request = new RestRequest(Method.GET); + request.RequestFormat = DataFormat.Json; + request.Resource = "/gui/"; + request.AddParameter("token", _authToken, ParameterType.QueryString); + + foreach (var argument in arguments) + { + request.AddParameter(argument.Key, argument.Value, ParameterType.QueryString); + } + + return ProcessRequest(request, client); + } + + private UTorrentResponse ProcessRequest(IRestRequest request, IRestClient client) + { + _logger.Debug("Url: {0}", client.BuildUri(request)); + var clientResponse = client.Execute(request); + + if (clientResponse.StatusCode == HttpStatusCode.BadRequest) + { + // Token has expired. If the settings were incorrect or the API is disabled we'd have gotten an error 400 during GetAuthToken + _logger.Debug("uTorrent authentication token error."); + + _authToken = GetAuthToken(client); + + request.Parameters.First(v => v.Name == "token").Value = _authToken; + clientResponse = client.Execute(request); + } + else if (clientResponse.StatusCode == HttpStatusCode.Unauthorized) + { + throw new DownloadClientAuthenticationException("Failed to authenticate"); + } + + var uTorrentResult = clientResponse.Read(client); + + return uTorrentResult; + } + + private String GetAuthToken(IRestClient client) + { + var request = new RestRequest(); + request.RequestFormat = DataFormat.Json; + request.Resource = "/gui/token.html"; + + _logger.Debug("Url: {0}", client.BuildUri(request)); + var response = client.Execute(request); + + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + throw new DownloadClientAuthenticationException("Failed to authenticate"); + } + + response.ValidateResponse(client); + + var xmlDoc = new System.Xml.XmlDocument(); + xmlDoc.LoadXml(response.Content); + + var authToken = xmlDoc.FirstChild.FirstChild.InnerText; + + _logger.Debug("uTorrent AuthToken={0}", authToken); + + return authToken; + } + + private IRestClient BuildClient(UTorrentSettings settings) + { + var url = String.Format(@"http://{0}:{1}", + settings.Host, + settings.Port); + + var restClient = RestClientFactory.BuildClient(url); + + restClient.Authenticator = new HttpBasicAuthenticator(settings.Username, settings.Password); + restClient.CookieContainer = _cookieContainer; + + if (_authToken.IsNullOrWhiteSpace()) + { + // µTorrent requires a token and cookie for authentication. The cookie is set automatically when getting the token. + _authToken = GetAuthToken(restClient); + } + + return restClient; + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentResponse.cs b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentResponse.cs new file mode 100644 index 000000000..606137d39 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentResponse.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.UTorrent +{ + public class UTorrentResponse + { + public Int32 Build { get; set; } + public List Torrents { get; set; } + public List Label { get; set; } + public List RssFeeds { get; set; } + public List RssFilters { get; set; } + + [JsonProperty(PropertyName = "torrentc")] + public String CacheNumber { get; set; } + + public List Settings { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentSettings.cs b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentSettings.cs new file mode 100644 index 000000000..c47eca8c4 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentSettings.cs @@ -0,0 +1,56 @@ +using System; +using FluentValidation; +using FluentValidation.Results; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.Download.Clients.UTorrent +{ + public class UTorrentSettingsValidator : AbstractValidator + { + public UTorrentSettingsValidator() + { + RuleFor(c => c.Host).NotEmpty(); + RuleFor(c => c.Port).InclusiveBetween(0, 65535); + RuleFor(c => c.TvCategory).NotEmpty(); + } + } + + public class UTorrentSettings : IProviderConfig + { + private static readonly UTorrentSettingsValidator validator = new UTorrentSettingsValidator(); + + public UTorrentSettings() + { + Host = "localhost"; + Port = 9091; + TvCategory = "tv-drone"; + } + + [FieldDefinition(0, Label = "Host", Type = FieldType.Textbox)] + public String Host { get; set; } + + [FieldDefinition(1, Label = "Port", Type = FieldType.Textbox)] + public Int32 Port { get; set; } + + [FieldDefinition(2, Label = "Username", Type = FieldType.Textbox)] + public String Username { get; set; } + + [FieldDefinition(3, Label = "Password", Type = FieldType.Password)] + public String Password { get; set; } + + [FieldDefinition(4, Label = "Category", Type = FieldType.Textbox)] + public String TvCategory { get; set; } + + [FieldDefinition(5, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(UTorrentPriority), HelpText = "Priority to use when grabbing episodes that aired within the last 14 days")] + public Int32 RecentTvPriority { get; set; } + + [FieldDefinition(6, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(UTorrentPriority), HelpText = "Priority to use when grabbing episodes that aired over 14 days ago")] + public Int32 OlderTvPriority { get; set; } + + public ValidationResult Validate() + { + return validator.Validate(this); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentTorrent.cs b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentTorrent.cs new file mode 100644 index 000000000..bea6b5a05 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentTorrent.cs @@ -0,0 +1,116 @@ +using System; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.UTorrent +{ + [JsonConverter(typeof(UTorrentTorrentJsonConverter))] + public class UTorrentTorrent + { + public String Hash { get; set; } + public UTorrentTorrentStatus Status { get; set; } + public String Name { get; set; } + public Int64 Size { get; set; } + public Double Progress { get; set; } + public Int64 Downloaded { get; set; } + public Int64 Uploaded { get; set; } + public Double Ratio { get; set; } + public Int32 UploadSpeed { get; set; } + public Int32 DownloadSpeed { get; set; } + + public Int32 Eta { get; set; } + public String Label { get; set; } + public Int32 PeersConnected { get; set; } + public Int32 PeersInSwarm { get; set; } + public Int32 SeedsConnected { get; set; } + public Int32 SeedsInSwarm { get; set; } + public Double Availablity { get; set; } + public Int32 TorrentQueueOrder { get; set; } + public Int64 Remaining { get; set; } + public String DownloadUrl { get; set; } + + public Object RssFeedUrl { get; set; } + public Object StatusMessage { get; set; } + public Object StreamId { get; set; } + public Object DateAdded { get; set; } + public Object DateCompleted { get; set; } + public Object AppUpdateUrl { get; set; } + public String RootDownloadPath { get; set; } + public Object Unknown27 { get; set; } + public Object Unknown28 { get; set; } + } + + class UTorrentTorrentJsonConverter : JsonConverter + { + public override bool CanConvert(Type objectType) + { + return objectType == typeof(UTorrentTorrent); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + var result = new UTorrentTorrent(); + + result.Hash = reader.ReadAsString(); + result.Status = (UTorrentTorrentStatus)reader.ReadAsInt32(); + result.Name = reader.ReadAsString(); + reader.Read(); + result.Size = (Int64)reader.Value; + result.Progress = (int)reader.ReadAsInt32() / 1000.0; + reader.Read(); + result.Downloaded = (Int64)reader.Value; + reader.Read(); + result.Uploaded = (Int64)reader.Value; + result.Ratio = (int)reader.ReadAsInt32() / 1000.0; + result.UploadSpeed = (int)reader.ReadAsInt32(); + result.DownloadSpeed = (int)reader.ReadAsInt32(); + + result.Eta = (int)reader.ReadAsInt32(); + result.Label = reader.ReadAsString(); + result.PeersConnected = (int)reader.ReadAsInt32(); + result.PeersInSwarm = (int)reader.ReadAsInt32(); + result.SeedsConnected = (int)reader.ReadAsInt32(); + result.SeedsInSwarm = (int)reader.ReadAsInt32(); + result.Availablity = (int)reader.ReadAsInt32() / 65536.0; + result.TorrentQueueOrder = (int)reader.ReadAsInt32(); + reader.Read(); + result.Remaining = (Int64)reader.Value; + + reader.Read(); + + // Builds before 25406 don't return the remaining items. + + if (reader.TokenType != JsonToken.EndArray) + { + result.DownloadUrl = (String)reader.Value; + + reader.Read(); + result.RssFeedUrl = reader.Value; + reader.Read(); + result.StatusMessage = reader.Value; + reader.Read(); + result.StreamId = reader.Value; + reader.Read(); + result.DateAdded = reader.Value; + reader.Read(); + result.DateCompleted = reader.Value; + reader.Read(); + result.AppUpdateUrl = reader.Value; + result.RootDownloadPath = reader.ReadAsString(); + reader.Read(); + result.Unknown27 = reader.Value; + reader.Read(); + result.Unknown28 = reader.Value; + + while(reader.TokenType != JsonToken.EndArray) + reader.Read(); + } + + return result; + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + throw new NotSupportedException(); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentTorrentStatus.cs b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentTorrentStatus.cs new file mode 100644 index 000000000..eb4f57da8 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentTorrentStatus.cs @@ -0,0 +1,17 @@ +using System; + +namespace NzbDrone.Core.Download.Clients.UTorrent +{ + [Flags] + public enum UTorrentTorrentStatus + { + Started = 1, + Checking = 2, + StartAfterCheck = 4, + Checked = 8, + Error = 16, + Paused = 32, + Queued = 64, + Loaded = 128 + } +} diff --git a/src/NzbDrone.Core/Download/CompletedDownloadService.cs b/src/NzbDrone.Core/Download/CompletedDownloadService.cs index 54cd15cd6..f9d6462bf 100644 --- a/src/NzbDrone.Core/Download/CompletedDownloadService.cs +++ b/src/NzbDrone.Core/Download/CompletedDownloadService.cs @@ -79,16 +79,16 @@ namespace NzbDrone.Core.Download else { - var downloadedEpisodesFolder = _configService.DownloadedEpisodesFolder; + var downloadedEpisodesFolder = new OsPath(_configService.DownloadedEpisodesFolder); var downloadItemOutputPath = trackedDownload.DownloadItem.OutputPath; - if (downloadItemOutputPath.IsNullOrWhiteSpace()) + if (downloadItemOutputPath.IsEmpty) { UpdateStatusMessage(trackedDownload, LogLevel.Warn, "Download doesn't contain intermediate path, ignoring download."); return; } - if (!downloadedEpisodesFolder.IsNullOrWhiteSpace() && (downloadedEpisodesFolder.PathEquals(downloadItemOutputPath) || downloadedEpisodesFolder.IsParentPath(downloadItemOutputPath))) + if (!downloadedEpisodesFolder.IsEmpty && downloadedEpisodesFolder.Contains(downloadItemOutputPath)) { UpdateStatusMessage(trackedDownload, LogLevel.Warn, "Intermediate Download path inside drone factory, ignoring download."); return; @@ -113,7 +113,7 @@ namespace NzbDrone.Core.Download public List Import(TrackedDownload trackedDownload, String overrideOutputPath = null) { var importResults = new List(); - var outputPath = overrideOutputPath ?? trackedDownload.DownloadItem.OutputPath; + var outputPath = overrideOutputPath ?? trackedDownload.DownloadItem.OutputPath.FullPath; if (_diskProvider.FolderExists(outputPath)) { @@ -216,16 +216,16 @@ namespace NzbDrone.Core.Download _logger.Debug("[{0}] Removing completed download from history.", trackedDownload.DownloadItem.Title); downloadClient.RemoveItem(trackedDownload.DownloadItem.DownloadClientId); - if (_diskProvider.FolderExists(trackedDownload.DownloadItem.OutputPath)) + if (_diskProvider.FolderExists(trackedDownload.DownloadItem.OutputPath.FullPath)) { _logger.Debug("Removing completed download directory: {0}", trackedDownload.DownloadItem.OutputPath); - _diskProvider.DeleteFolder(trackedDownload.DownloadItem.OutputPath, true); + _diskProvider.DeleteFolder(trackedDownload.DownloadItem.OutputPath.FullPath, true); } - else if (_diskProvider.FileExists(trackedDownload.DownloadItem.OutputPath)) + else if (_diskProvider.FileExists(trackedDownload.DownloadItem.OutputPath.FullPath)) { _logger.Debug("Removing completed download file: {0}", trackedDownload.DownloadItem.OutputPath); - _diskProvider.DeleteFile(trackedDownload.DownloadItem.OutputPath); + _diskProvider.DeleteFile(trackedDownload.DownloadItem.OutputPath.FullPath); } trackedDownload.State = TrackedDownloadState.Removed; diff --git a/src/NzbDrone.Core/Download/DownloadClientItem.cs b/src/NzbDrone.Core/Download/DownloadClientItem.cs index e82cde7bd..11c360994 100644 --- a/src/NzbDrone.Core/Download/DownloadClientItem.cs +++ b/src/NzbDrone.Core/Download/DownloadClientItem.cs @@ -1,4 +1,6 @@ using System; +using NzbDrone.Common.Disk; +using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.Download { @@ -14,7 +16,7 @@ namespace NzbDrone.Core.Download public TimeSpan? DownloadTime { get; set; } public TimeSpan? RemainingTime { get; set; } - public String OutputPath { get; set; } + public OsPath OutputPath { get; set; } public String Message { get; set; } public DownloadItemStatus Status { get; set; } diff --git a/src/NzbDrone.Core/Download/DownloadClientStatus.cs b/src/NzbDrone.Core/Download/DownloadClientStatus.cs index ef4f71b38..6bb47c3a2 100644 --- a/src/NzbDrone.Core/Download/DownloadClientStatus.cs +++ b/src/NzbDrone.Core/Download/DownloadClientStatus.cs @@ -2,12 +2,13 @@ using System.Collections.Generic; using System.Linq; using System.Text; +using NzbDrone.Common.Disk; namespace NzbDrone.Core.Download { public class DownloadClientStatus { public Boolean IsLocalhost { get; set; } - public List OutputRootFolders { get; set; } + public List OutputRootFolders { get; set; } } } diff --git a/src/NzbDrone.Core/Download/DownloadTrackingService.cs b/src/NzbDrone.Core/Download/DownloadTrackingService.cs index db119f0f4..ed8f4d156 100644 --- a/src/NzbDrone.Core/Download/DownloadTrackingService.cs +++ b/src/NzbDrone.Core/Download/DownloadTrackingService.cs @@ -135,7 +135,16 @@ namespace NzbDrone.Core.Download foreach (var downloadClient in downloadClients) { - var downloadClientHistory = downloadClient.GetItems().ToList(); + List downloadClientHistory; + try + { + downloadClientHistory = downloadClient.GetItems().ToList(); + } + catch (Exception ex) + { + _logger.WarnException("Unable to retrieve queue and history items from " + downloadClient.Definition.Name, ex); + continue; + } foreach (var downloadItem in downloadClientHistory) { var trackingId = String.Format("{0}-{1}", downloadClient.Definition.Id, downloadItem.DownloadClientId); diff --git a/src/NzbDrone.Core/Download/TorrentClientBase.cs b/src/NzbDrone.Core/Download/TorrentClientBase.cs new file mode 100644 index 000000000..4b4e30b61 --- /dev/null +++ b/src/NzbDrone.Core/Download/TorrentClientBase.cs @@ -0,0 +1,180 @@ +using System; +using System.IO; +using System.Linq; +using System.Collections.Generic; +using System.Net; +using MonoTorrent; +using NzbDrone.Common; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Exceptions; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.MediaFiles.TorrentInfo; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Configuration; +using NLog; +using NzbDrone.Core.RemotePathMappings; + +namespace NzbDrone.Core.Download +{ + public abstract class TorrentClientBase : DownloadClientBase + where TSettings : IProviderConfig, new() + { + protected readonly IHttpClient _httpClient; + protected readonly ITorrentFileInfoReader _torrentFileInfoReader; + + protected TorrentClientBase(ITorrentFileInfoReader torrentFileInfoReader, + IHttpClient httpClient, + IConfigService configService, + IDiskProvider diskProvider, + IParsingService parsingService, + IRemotePathMappingService remotePathMappingService, + Logger logger) + : base(configService, diskProvider, remotePathMappingService, logger) + { + _httpClient = httpClient; + _torrentFileInfoReader = torrentFileInfoReader; + } + + public override DownloadProtocol Protocol + { + get + { + return DownloadProtocol.Torrent; + } + } + + protected abstract String AddFromMagnetLink(RemoteEpisode remoteEpisode, String hash, String magnetLink); + protected abstract String AddFromTorrentFile(RemoteEpisode remoteEpisode, String hash, String filename, Byte[] fileContent); + + public override String Download(RemoteEpisode remoteEpisode) + { + var torrentInfo = remoteEpisode.Release as TorrentInfo; + + String magnetUrl = null; + String torrentUrl = null; + + if (remoteEpisode.Release.DownloadUrl.StartsWith("magnet:")) + { + magnetUrl = remoteEpisode.Release.DownloadUrl; + } + else + { + torrentUrl = remoteEpisode.Release.DownloadUrl; + } + + if (torrentInfo != null && !torrentInfo.MagnetUrl.IsNullOrWhiteSpace()) + { + magnetUrl = torrentInfo.MagnetUrl; + } + + String hash = null; + + if (!magnetUrl.IsNullOrWhiteSpace()) + { + hash = DownloadFromMagnetUrl(remoteEpisode, magnetUrl); + } + + if (hash == null && !torrentUrl.IsNullOrWhiteSpace()) + { + hash = DownloadFromWebUrl(remoteEpisode, torrentUrl); + } + + if (hash == null) + { + throw new ReleaseDownloadException(remoteEpisode.Release, "Downloading torrent failed"); + } + + return hash; + } + + private string DownloadFromWebUrl(RemoteEpisode remoteEpisode, String torrentUrl) + { + Byte[] torrentFile = null; + + try + { + var request = new HttpRequest(torrentUrl); + request.Headers.Accept = "application/x-bittorrent"; + request.AllowAutoRedirect = false; + + var response = _httpClient.Get(request); + + if (response.StatusCode == HttpStatusCode.SeeOther) + { + var locationHeader = (string)response.Headers.GetValueOrDefault("Location", null); + + if (locationHeader != null && locationHeader.StartsWith("magnet:")) + { + return DownloadFromMagnetUrl(remoteEpisode, locationHeader); + } + else + { + throw new WebException("Remote website tried to redirect without providing a location."); + } + } + + torrentFile = response.ResponseData; + } + catch (WebException ex) + { + _logger.ErrorException(String.Format("Downloading torrentfile for episode '{0}' failed ({1})", + remoteEpisode.Release.Title, torrentUrl), ex); + + throw new ReleaseDownloadException(remoteEpisode.Release, "Downloading torrent failed", ex); + } + + + var filename = String.Format("{0}.torrent", FileNameBuilder.CleanFileName(remoteEpisode.Release.Title)); + + var hash = _torrentFileInfoReader.GetHashFromTorrentFile(torrentFile); + + var actualHash = AddFromTorrentFile(remoteEpisode, hash, filename, torrentFile); + + if (hash != actualHash) + { + _logger.Warn( + "{0} did not return the expected InfoHash for '{1}', NzbDrone could potential lose track of the download in progress.", + Definition.Implementation, remoteEpisode.Release.DownloadUrl); + } + + return actualHash; + } + + private String DownloadFromMagnetUrl(RemoteEpisode remoteEpisode, String magnetUrl) + { + String hash = null; + String actualHash = null; + + try + { + hash = new MagnetLink(magnetUrl).InfoHash.ToHex(); + } + catch (FormatException ex) + { + _logger.ErrorException(String.Format("Failed to parse magnetlink for episode '{0}': '{1}'", + remoteEpisode.Release.Title, magnetUrl), ex); + + return null; + } + + if (hash != null) + { + actualHash = AddFromMagnetLink(remoteEpisode, hash, magnetUrl); + } + + if (hash != actualHash) + { + _logger.Warn( + "{0} did not return the expected InfoHash for '{1}', NzbDrone could potential lose track of the download in progress.", + Definition.Implementation, remoteEpisode.Release.DownloadUrl); + } + + return actualHash; + } + } +} diff --git a/src/NzbDrone.Core/HealthCheck/Checks/ImportMechanismCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/ImportMechanismCheck.cs index 6a0ca2f7a..eb21be154 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/ImportMechanismCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/ImportMechanismCheck.cs @@ -25,12 +25,12 @@ namespace NzbDrone.Core.HealthCheck.Checks public override HealthCheck Check() { - var droneFactoryFolder = _configService.DownloadedEpisodesFolder; + var droneFactoryFolder = new OsPath(_configService.DownloadedEpisodesFolder); var downloadClients = _provideDownloadClient.GetDownloadClients().Select(v => new { downloadClient = v, status = v.GetStatus() }).ToList(); var downloadClientIsLocalHost = downloadClients.All(v => v.status.IsLocalhost); - var downloadClientOutputInDroneFactory = !droneFactoryFolder.IsNullOrWhiteSpace() - && downloadClients.Any(v => v.status.OutputRootFolders != null && v.status.OutputRootFolders.Contains(droneFactoryFolder, PathEqualityComparer.Instance)); + var downloadClientOutputInDroneFactory = !droneFactoryFolder.IsEmpty + && downloadClients.Any(v => v.status.OutputRootFolders != null && v.status.OutputRootFolders.Any(droneFactoryFolder.Contains)); if (!_configService.IsDefined("EnableCompletedDownloadHandling")) { @@ -66,14 +66,14 @@ namespace NzbDrone.Core.HealthCheck.Checks } } - if (!_configService.EnableCompletedDownloadHandling && droneFactoryFolder.IsNullOrWhiteSpace()) + if (!_configService.EnableCompletedDownloadHandling && droneFactoryFolder.IsEmpty) { return new HealthCheck(GetType(), HealthCheckResult.Warning, "Enable Completed Download Handling or configure Drone factory"); } - if (_configService.EnableCompletedDownloadHandling && !droneFactoryFolder.IsNullOrWhiteSpace()) + if (_configService.EnableCompletedDownloadHandling && !droneFactoryFolder.IsEmpty) { - if (_downloadTrackingService.GetCompletedDownloads().Any(v => droneFactoryFolder.PathEquals(v.DownloadItem.OutputPath) || droneFactoryFolder.IsParentPath(v.DownloadItem.OutputPath))) + if (_downloadTrackingService.GetCompletedDownloads().Any(v => droneFactoryFolder.Contains(v.DownloadItem.OutputPath))) { return new HealthCheck(GetType(), HealthCheckResult.Warning, "Completed Download Handling conflict with Drone Factory (Conflicting History Item)", "Migrating-to-Completed-Download-Handling#conflicting-download-client-category"); } diff --git a/src/NzbDrone.Core/Indexers/BitMeTv/BitMeTv.cs b/src/NzbDrone.Core/Indexers/BitMeTv/BitMeTv.cs new file mode 100644 index 000000000..0fd5ad2f3 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/BitMeTv/BitMeTv.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using NzbDrone.Core.ThingiProvider; +using FluentValidation.Results; +using System.Linq; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NLog; +using NzbDrone.Core.Parser; + +namespace NzbDrone.Core.Indexers.BitMeTv +{ + public class BitMeTv : HttpIndexerBase + { + public override DownloadProtocol Protocol { get { return DownloadProtocol.Torrent; } } + public override Boolean SupportsSearch { get { return false; } } + public override Int32 PageSize { get { return 0; } } + + public BitMeTv(IHttpClient httpClient, IConfigService configService, IParsingService parsingService, Logger logger) + : base(httpClient, configService, parsingService, logger) + { + + } + + public override IIndexerRequestGenerator GetRequestGenerator() + { + return new BitMeTvRequestGenerator() { Settings = Settings }; + } + + public override IParseIndexerResponse GetParser() + { + return new TorrentRssParser() { ParseSizeInDescription = true }; + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Indexers/BitMeTv/BitMeTvRequestGenerator.cs b/src/NzbDrone.Core/Indexers/BitMeTv/BitMeTvRequestGenerator.cs new file mode 100644 index 000000000..6cdd1bad2 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/BitMeTv/BitMeTvRequestGenerator.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using NzbDrone.Common; +using NzbDrone.Common.Http; +using NzbDrone.Core.IndexerSearch.Definitions; + +namespace NzbDrone.Core.Indexers.BitMeTv +{ + public class BitMeTvRequestGenerator : IIndexerRequestGenerator + { + public BitMeTvSettings Settings { get; set; } + + public virtual IList> GetRecentRequests() + { + var pageableRequests = new List>(); + + pageableRequests.AddIfNotNull(GetRssRequests(null)); + + return pageableRequests; + } + + public virtual IList> GetSearchRequests(SingleEpisodeSearchCriteria searchCriteria) + { + return new List>(); + } + + public virtual IList> GetSearchRequests(SeasonSearchCriteria searchCriteria) + { + return new List>(); + } + + public virtual IList> GetSearchRequests(DailyEpisodeSearchCriteria searchCriteria) + { + return new List>(); + } + + public virtual IList> GetSearchRequests(AnimeEpisodeSearchCriteria searchCriteria) + { + return new List>(); + } + + public virtual IList> GetSearchRequests(SpecialEpisodeSearchCriteria searchCriteria) + { + return new List>(); + } + + private IEnumerable GetRssRequests(String searchParameters) + { + yield return new IndexerRequest(String.Format("{0}/rss.php?uid={1}&passkey={2}{3}", Settings.BaseUrl.Trim().TrimEnd('/'), Settings.UserId, Settings.RssPasskey, searchParameters), HttpAccept.Html); + } + } +} diff --git a/src/NzbDrone.Core/Indexers/BitMeTv/BitMeTvSettings.cs b/src/NzbDrone.Core/Indexers/BitMeTv/BitMeTvSettings.cs new file mode 100644 index 000000000..4accd7c1a --- /dev/null +++ b/src/NzbDrone.Core/Indexers/BitMeTv/BitMeTvSettings.cs @@ -0,0 +1,44 @@ +using System; +using System.Linq; +using FluentValidation; +using FluentValidation.Results; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Indexers.BitMeTv +{ + public class BitMeTvSettingsValidator : AbstractValidator + { + public BitMeTvSettingsValidator() + { + RuleFor(c => c.BaseUrl).ValidRootUrl(); + RuleFor(c => c.UserId).NotEmpty(); + RuleFor(c => c.RssPasskey).NotEmpty(); + } + } + + public class BitMeTvSettings : IProviderConfig + { + private static readonly BitMeTvSettingsValidator validator = new BitMeTvSettingsValidator(); + + public BitMeTvSettings() + { + BaseUrl = "http://www.bitmetv.org"; + } + + [FieldDefinition(0, Label = "Website URL")] + public String BaseUrl { get; set; } + + [FieldDefinition(1, Label = "UserId")] + public String UserId { get; set; } + + [FieldDefinition(2, Label = "RSS Passkey")] + public String RssPasskey { get; set; } + + public ValidationResult Validate() + { + return validator.Validate(this); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNet.cs b/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNet.cs new file mode 100644 index 000000000..f5c5e6073 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNet.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.Indexers.BroadcastheNet +{ + public class BroadcastheNet : HttpIndexerBase + { + public override DownloadProtocol Protocol { get { return DownloadProtocol.Torrent; } } + public override bool SupportsRss { get { return true; } } + public override bool SupportsSearch { get { return true; } } + public override int PageSize { get { return 100; } } + + public BroadcastheNet(IHttpClient httpClient, IConfigService configService, IParsingService parsingService, Logger logger) + : base(httpClient, configService, parsingService, logger) + { + + } + + public override IIndexerRequestGenerator GetRequestGenerator() + { + return new BroadcastheNetRequestGenerator() { Settings = Settings, PageSize = PageSize }; + } + + public override IParseIndexerResponse GetParser() + { + return new BroadcastheNetParser(); + } + } +} diff --git a/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetParser.cs b/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetParser.cs new file mode 100644 index 000000000..d639b56ef --- /dev/null +++ b/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetParser.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Indexers.Exceptions; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.Indexers.BroadcastheNet +{ + public class BroadcastheNetParser : IParseIndexerResponse + { + public IList ParseResponse(IndexerResponse indexerResponse) + { + var results = new List(); + + if (indexerResponse.HttpResponse.StatusCode == System.Net.HttpStatusCode.Unauthorized) + { + throw new ApiKeyException("API Key invalid or not authorized"); + } + else if (indexerResponse.HttpResponse.StatusCode == System.Net.HttpStatusCode.NotFound) + { + throw new IndexerException(indexerResponse, "Indexer API call returned NotFound, the Indexer API may have changed."); + } + else if (indexerResponse.HttpResponse.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable) + { + throw new RequestLimitReachedException("Cannot do more than 150 API requests per hour."); + } + else if (indexerResponse.HttpResponse.StatusCode != System.Net.HttpStatusCode.OK) + { + throw new IndexerException(indexerResponse, "Indexer API call returned an unexpected StatusCode [{0}]", indexerResponse.HttpResponse.StatusCode); + } + + var jsonResponse = new HttpResponse>(indexerResponse.HttpResponse).Resource; + + if (jsonResponse.Error != null || jsonResponse.Result == null) + { + throw new IndexerException(indexerResponse, "Indexer API call returned an error [{0}]", jsonResponse.Error); + } + + if (jsonResponse.Result.Results == 0) + { + return results; + } + + foreach (var torrent in jsonResponse.Result.Torrents.Values) + { + var torrentInfo = new TorrentInfo(); + + torrentInfo.Guid = String.Format("BTN-{0}", torrent.TorrentID.ToString()); + torrentInfo.Title = torrent.ReleaseName; + torrentInfo.Size = torrent.Size; + torrentInfo.DownloadUrl = torrent.DownloadURL; + torrentInfo.InfoUrl = String.Format("https://broadcasthe.net/torrents.php?id={0}&torrentid={1}", torrent.GroupID, torrent.TorrentID); + //torrentInfo.CommentUrl = + if (torrent.TvrageID.HasValue) + { + torrentInfo.TvRageId = torrent.TvrageID.Value; + } + torrentInfo.PublishDate = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc).ToUniversalTime().AddSeconds(torrent.Time); + //torrentInfo.MagnetUrl = + torrentInfo.InfoHash = torrent.InfoHash; + torrentInfo.Seeds = torrent.Seeders; + torrentInfo.Peers = torrent.Leechers; + + results.Add(torrentInfo); + } + + return results; + } + } +} diff --git a/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetRequestGenerator.cs b/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetRequestGenerator.cs new file mode 100644 index 000000000..4830fc6b8 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetRequestGenerator.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using NzbDrone.Common; +using NzbDrone.Common.Http; +using NzbDrone.Core.IndexerSearch.Definitions; + +namespace NzbDrone.Core.Indexers.BroadcastheNet +{ + public class BroadcastheNetRequestGenerator : IIndexerRequestGenerator + { + public Int32 MaxPages { get; set; } + public Int32 PageSize { get; set; } + public BroadcastheNetSettings Settings { get; set; } + + public BroadcastheNetRequestGenerator() + { + MaxPages = 10; + PageSize = 100; + } + + public virtual IList> GetRecentRequests() + { + var pageableRequests = new List>(); + + pageableRequests.AddIfNotNull(GetPagedRequests(1, null)); + + return pageableRequests; + } + + public virtual IList> GetSearchRequests(SingleEpisodeSearchCriteria searchCriteria) + { + var pageableRequest = new List>(); + + var parameters = new BroadcastheNetTorrentQuery(); + if (AddSeriesSearchParameters(parameters, searchCriteria)) + { + parameters.Category = "Episode"; + parameters.Name = String.Format("S{0:00}E{1:00}", searchCriteria.SeasonNumber, searchCriteria.EpisodeNumber); + + pageableRequest.AddIfNotNull(GetPagedRequests(MaxPages, parameters)); + } + + return pageableRequest; + } + + public virtual IList> GetSearchRequests(SeasonSearchCriteria searchCriteria) + { + var pageableRequest = new List>(); + + var parameters = new BroadcastheNetTorrentQuery(); + if (AddSeriesSearchParameters(parameters, searchCriteria)) + { + parameters.Category = "Episode"; + parameters.Name = String.Format("S{0:00}E%", searchCriteria.SeasonNumber); + + pageableRequest.AddIfNotNull(GetPagedRequests(MaxPages, parameters)); + + parameters = parameters.Clone(); + + parameters.Category = "Season"; + parameters.Name = String.Format("Season {0}", searchCriteria.SeasonNumber); + + pageableRequest.AddIfNotNull(GetPagedRequests(MaxPages, parameters)); + } + + + return pageableRequest; + } + + public virtual IList> GetSearchRequests(DailyEpisodeSearchCriteria searchCriteria) + { + var pageableRequest = new List>(); + + var parameters = new BroadcastheNetTorrentQuery(); + if (AddSeriesSearchParameters(parameters, searchCriteria)) + { + parameters.Category = "Episode"; + parameters.Name = String.Format("{0:yyyy}.{0:MM}.{0:dd}", searchCriteria.AirDate); + + pageableRequest.AddIfNotNull(GetPagedRequests(MaxPages, parameters)); + } + + return pageableRequest; + } + + public virtual IList> GetSearchRequests(AnimeEpisodeSearchCriteria searchCriteria) + { + return new List>(); + } + + public virtual IList> GetSearchRequests(SpecialEpisodeSearchCriteria searchCriteria) + { + return new List>(); + } + + private bool AddSeriesSearchParameters(BroadcastheNetTorrentQuery parameters, SearchCriteriaBase searchCriteria) + { + if (searchCriteria.Series.TvRageId != 0) + { + parameters.Tvrage = String.Format("{0}", searchCriteria.Series.TvRageId); + return true; + } + else if (searchCriteria.Series.TvdbId != 0) + { + parameters.Tvdb = String.Format("{0}", searchCriteria.Series.TvdbId); + return true; + } + else + { + // BTN is very neatly managed, so it's unlikely they map tvrage/tvdb wrongly. + return false; + } + } + + private IEnumerable GetPagedRequests(Int32 maxPages, BroadcastheNetTorrentQuery parameters) + { + if (parameters == null) + { + parameters = new BroadcastheNetTorrentQuery(); + } + + var builder = new JsonRpcRequestBuilder(Settings.BaseUrl, "getTorrents", new Object[] { Settings.ApiKey, parameters, PageSize, 0 }); + builder.SupressHttpError = true; + + for (var page = 0; page < maxPages;page++) + { + builder.Parameters[3] = page * PageSize; + + yield return new IndexerRequest(builder.Build("")); + } + } + } +} diff --git a/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetSettings.cs b/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetSettings.cs new file mode 100644 index 000000000..f321f5c3a --- /dev/null +++ b/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetSettings.cs @@ -0,0 +1,40 @@ +using System; +using System.Linq; +using FluentValidation; +using FluentValidation.Results; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Indexers.BroadcastheNet +{ + public class BroadcastheNetSettingsValidator : AbstractValidator + { + public BroadcastheNetSettingsValidator() + { + RuleFor(c => c.BaseUrl).ValidRootUrl(); + RuleFor(c => c.ApiKey).NotEmpty(); + } + } + + public class BroadcastheNetSettings : IProviderConfig + { + private static readonly BroadcastheNetSettingsValidator validator = new BroadcastheNetSettingsValidator(); + + public BroadcastheNetSettings() + { + BaseUrl = "http://api.btnapps.net/"; + } + + [FieldDefinition(0, Label = "API URL", Advanced = true, HelpText = "Do not change this unless you know what you're doing. Since your API key will be sent to that host.")] + public String BaseUrl { get; set; } + + [FieldDefinition(1, Label = "API Key")] + public String ApiKey { get; set; } + + public ValidationResult Validate() + { + return validator.Validate(this); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetTorrent.cs b/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetTorrent.cs new file mode 100644 index 000000000..89e9f7784 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetTorrent.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Indexers.BroadcastheNet +{ + public class BroadcastheNetTorrent + { + public String GroupName { get; set; } + public Int32 GroupID { get; set; } + public Int32 TorrentID { get; set; } + public Int32 SeriesID { get; set; } + public String Series { get; set; } + public String SeriesBanner { get; set; } + public String SeriesPoster { get; set; } + public String YoutubeTrailer { get; set; } + public String Category { get; set; } + public Int32? Snatched { get; set; } + public Int32? Seeders { get; set; } + public Int32? Leechers { get; set; } + public String Source { get; set; } + public String Container { get; set; } + public String Codec { get; set; } + public String Resolution { get; set; } + public String Origin { get; set; } + public String ReleaseName { get; set; } + public Int64 Size { get; set; } + public Int64 Time { get; set; } + public Int32? TvdbID { get; set; } + public Int32? TvrageID { get; set; } + public String ImdbID { get; set; } + public String InfoHash { get; set; } + public String DownloadURL { get; set; } + } +} diff --git a/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetTorrentQuery.cs b/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetTorrentQuery.cs new file mode 100644 index 000000000..0baf40022 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetTorrentQuery.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Indexers.BroadcastheNet +{ + public class BroadcastheNetTorrentQuery + { + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + public String Id { get; set; } + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + public String Category { get; set; } + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + public String Name { get; set; } + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + public String Search { get; set; } + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + public String Codec { get; set; } + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + public String Container { get; set; } + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + public String Source { get; set; } + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + public String Resolution { get; set; } + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + public String Origin { get; set; } + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + public String Hash { get; set; } + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + public String Tvdb { get; set; } + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + public String Tvrage { get; set; } + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + public String Age { get; set; } + + public BroadcastheNetTorrentQuery Clone() + { + return MemberwiseClone() as BroadcastheNetTorrentQuery; + } + } +} diff --git a/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetTorrents.cs b/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetTorrents.cs new file mode 100644 index 000000000..98bc2c2d7 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetTorrents.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Indexers.BroadcastheNet +{ + public class BroadcastheNetTorrents + { + public Dictionary Torrents { get; set; } + public Int32 Results { get; set; } + } +} diff --git a/src/NzbDrone.Core/Indexers/DownloadProtocol.cs b/src/NzbDrone.Core/Indexers/DownloadProtocol.cs index eac150ce6..0bdde311e 100644 --- a/src/NzbDrone.Core/Indexers/DownloadProtocol.cs +++ b/src/NzbDrone.Core/Indexers/DownloadProtocol.cs @@ -7,6 +7,7 @@ namespace NzbDrone.Core.Indexers { public enum DownloadProtocol { + Unknown = 0, Usenet = 1, Torrent = 2 } diff --git a/src/NzbDrone.Core/Indexers/Exceptions/IndexerException.cs b/src/NzbDrone.Core/Indexers/Exceptions/IndexerException.cs index 2d6739199..942698210 100644 --- a/src/NzbDrone.Core/Indexers/Exceptions/IndexerException.cs +++ b/src/NzbDrone.Core/Indexers/Exceptions/IndexerException.cs @@ -1,16 +1,24 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using NzbDrone.Common.Exceptions; +using NzbDrone.Common.Exceptions; namespace NzbDrone.Core.Indexers.Exceptions { public class IndexerException : NzbDroneException { - public IndexerException(string message, params object[] args) + private readonly IndexerResponse _indexerResponse; + + public IndexerException(IndexerResponse response, string message, params object[] args) : base(message, args) { } + + public IndexerException(IndexerResponse response, string message) + : base(message) + { + } + + public IndexerResponse Response + { + get { return _indexerResponse; } + } } } diff --git a/src/NzbDrone.Core/Indexers/EzrssTorrentRssParser.cs b/src/NzbDrone.Core/Indexers/EzrssTorrentRssParser.cs new file mode 100644 index 000000000..405bdc811 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/EzrssTorrentRssParser.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Xml.Linq; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.Indexers +{ + public class EzrssTorrentRssParser : TorrentRssParser + { + public const String ns = "{http://xmlns.ezrss.it/0.1/}"; + + public EzrssTorrentRssParser() + { + UseGuidInfoUrl = true; + UseEnclosureLength = false; + UseEnclosureUrl = true; + } + + protected virtual XElement GetEzrssElement(XElement item, String name) + { + var element = item.Element(ns + name); + + if (element == null) + { + element = item.Element(ns + "torrent"); + if (element != null) + { + element = element.Element(ns + name); + } + } + + return element; + } + + protected override Int64 GetSize(XElement item) + { + var contentLength = GetEzrssElement(item, "contentLength"); + + if (contentLength != null) + { + return (Int64)contentLength; + } + + return base.GetSize(item); + } + + protected override String GetInfoHash(XElement item) + { + var infoHash = GetEzrssElement(item, "infoHash"); + + return (String)infoHash; + } + + protected override String GetMagnetUrl(XElement item) + { + var magnetURI = GetEzrssElement(item, "magnetURI"); + + return (String)magnetURI; + } + + protected override Int32? GetSeeders(XElement item) + { + var seeds = GetEzrssElement(item, "seeds"); + + if (seeds != null) + { + return (Int32)seeds; + } + + return base.GetPeers(item); + } + + protected override Int32? GetPeers(XElement item) + { + var peers = GetEzrssElement(item, "peers"); + + if (peers != null) + { + return (Int32)peers; + } + + return base.GetPeers(item); + } + } +} diff --git a/src/NzbDrone.Core/Indexers/Eztv/Eztv.cs b/src/NzbDrone.Core/Indexers/Eztv/Eztv.cs new file mode 100644 index 000000000..9fb14b0fe --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Eztv/Eztv.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using NzbDrone.Core.ThingiProvider; +using FluentValidation.Results; +using System.Linq; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Parser; +using NLog; + +namespace NzbDrone.Core.Indexers.Eztv +{ + public class Eztv : HttpIndexerBase + { + public override DownloadProtocol Protocol { get { return DownloadProtocol.Torrent; } } + + public Eztv(IHttpClient httpClient, IConfigService configService, IParsingService parsingService, Logger logger) + : base(httpClient, configService, parsingService, logger) + { + + } + + public override IIndexerRequestGenerator GetRequestGenerator() + { + return new EztvRequestGenerator() { Settings = Settings }; + } + + public override IParseIndexerResponse GetParser() + { + return new EzrssTorrentRssParser(); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Indexers/Eztv/EztvRequestGenerator.cs b/src/NzbDrone.Core/Indexers/Eztv/EztvRequestGenerator.cs new file mode 100644 index 000000000..92107e05f --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Eztv/EztvRequestGenerator.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using NzbDrone.Common; +using NzbDrone.Common.Http; +using NzbDrone.Core.IndexerSearch.Definitions; + +namespace NzbDrone.Core.Indexers.Eztv +{ + public class EztvRequestGenerator : IIndexerRequestGenerator + { + public EztvSettings Settings { get; set; } + + public EztvRequestGenerator() + { + + } + + public virtual IList> GetRecentRequests() + { + var pageableRequests = new List>(); + + pageableRequests.AddIfNotNull(GetPagedRequests("/feed/")); + + return pageableRequests; + } + + public virtual IList> GetSearchRequests(SingleEpisodeSearchCriteria searchCriteria) + { + var pageableRequests = new List>(); + + foreach (var queryTitle in searchCriteria.QueryTitles) + { + pageableRequests.AddIfNotNull(GetPagedRequests(String.Format("/search/index.php?show_name={0}&season={1}&episode={2}&mode=rss", + queryTitle, + searchCriteria.SeasonNumber, + searchCriteria.EpisodeNumber))); + } + + return pageableRequests; + } + + public virtual IList> GetSearchRequests(SeasonSearchCriteria searchCriteria) + { + var pageableRequests = new List>(); + + foreach (var queryTitle in searchCriteria.QueryTitles) + { + pageableRequests.AddIfNotNull(GetPagedRequests(String.Format("/search/index.php?show_name={0}&season={1}&mode=rss", + queryTitle, + searchCriteria.SeasonNumber))); + } + + return pageableRequests; + } + + public virtual IList> GetSearchRequests(DailyEpisodeSearchCriteria searchCriteria) + { + //EZTV doesn't support searching based on actual episode airdate. they only support release date. + return new List>(); + } + + public virtual IList> GetSearchRequests(AnimeEpisodeSearchCriteria searchCriteria) + { + return new List>(); + } + + public virtual IList> GetSearchRequests(SpecialEpisodeSearchCriteria searchCriteria) + { + return new List>(); + } + + private IEnumerable GetPagedRequests(String query) + { + yield return new IndexerRequest(Settings.BaseUrl.TrimEnd('/') + query, HttpAccept.Rss); + } + } +} diff --git a/src/NzbDrone.Core/Indexers/Eztv/EztvSettings.cs b/src/NzbDrone.Core/Indexers/Eztv/EztvSettings.cs new file mode 100644 index 000000000..ee6045fdf --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Eztv/EztvSettings.cs @@ -0,0 +1,36 @@ +using System; +using System.Linq; +using FluentValidation; +using FluentValidation.Results; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Indexers.Eztv +{ + public class EztvSettingsValidator : AbstractValidator + { + public EztvSettingsValidator() + { + RuleFor(c => c.BaseUrl).ValidRootUrl(); + } + } + + public class EztvSettings : IProviderConfig + { + private static readonly EztvSettingsValidator validator = new EztvSettingsValidator(); + + public EztvSettings() + { + BaseUrl = "http://www.ezrss.it/"; + } + + [FieldDefinition(0, Label = "Website URL")] + public String BaseUrl { get; set; } + + public ValidationResult Validate() + { + return validator.Validate(this); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs b/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs index 2dc971663..12c88623a 100644 --- a/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs +++ b/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs @@ -191,12 +191,6 @@ namespace NzbDrone.Core.Indexers _logger.Debug("Downloading Feed " + request.Url); var response = new IndexerResponse(request, _httpClient.Execute(request.HttpRequest)); - if (response.HttpResponse.Headers.ContentType != null && response.HttpResponse.Headers.ContentType.Contains("text/html") && - request.HttpRequest.Headers.Accept != null && !request.HttpRequest.Headers.Accept.Contains("text/html")) - { - throw new WebException("Indexer responded with html content. Site is likely blocked or unavailable."); - } - return parser.ParseResponse(response).ToList(); } diff --git a/src/NzbDrone.Core/Indexers/IPTorrents/IPTorrents.cs b/src/NzbDrone.Core/Indexers/IPTorrents/IPTorrents.cs new file mode 100644 index 000000000..274329d6c --- /dev/null +++ b/src/NzbDrone.Core/Indexers/IPTorrents/IPTorrents.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using NzbDrone.Core.ThingiProvider; +using FluentValidation.Results; +using System.Linq; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Parser; +using NLog; + +namespace NzbDrone.Core.Indexers.IPTorrents +{ + public class IPTorrents : HttpIndexerBase + { + public override DownloadProtocol Protocol { get { return DownloadProtocol.Torrent; } } + public override Boolean SupportsSearch { get { return false; } } + public override Int32 PageSize { get { return 0; } } + + public IPTorrents(IHttpClient httpClient, IConfigService configService, IParsingService parsingService, Logger logger) + : base(httpClient, configService, parsingService, logger) + { + + } + + public override IIndexerRequestGenerator GetRequestGenerator() + { + return new IPTorrentsRequestGenerator() { Settings = Settings }; + } + + public override IParseIndexerResponse GetParser() + { + return new TorrentRssParser() { ParseSizeInDescription = true }; + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Indexers/IPTorrents/IPTorrentsRequestGenerator.cs b/src/NzbDrone.Core/Indexers/IPTorrents/IPTorrentsRequestGenerator.cs new file mode 100644 index 000000000..6ef26f583 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/IPTorrents/IPTorrentsRequestGenerator.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using NzbDrone.Common; +using NzbDrone.Common.Http; +using NzbDrone.Core.IndexerSearch.Definitions; + +namespace NzbDrone.Core.Indexers.IPTorrents +{ + public class IPTorrentsRequestGenerator : IIndexerRequestGenerator + { + public IPTorrentsSettings Settings { get; set; } + + public virtual IList> GetRecentRequests() + { + var pageableRequests = new List>(); + + pageableRequests.AddIfNotNull(GetRssRequests()); + + return pageableRequests; + } + + public virtual IList> GetSearchRequests(SingleEpisodeSearchCriteria searchCriteria) + { + return new List>(); + } + + public virtual IList> GetSearchRequests(SeasonSearchCriteria searchCriteria) + { + return new List>(); + } + + public virtual IList> GetSearchRequests(DailyEpisodeSearchCriteria searchCriteria) + { + return new List>(); + } + + public virtual IList> GetSearchRequests(AnimeEpisodeSearchCriteria searchCriteria) + { + return new List>(); + } + + public virtual IList> GetSearchRequests(SpecialEpisodeSearchCriteria searchCriteria) + { + return new List>(); + } + + private IEnumerable GetRssRequests() + { + yield return new IndexerRequest(Settings.Url, HttpAccept.Rss); + } + } +} diff --git a/src/NzbDrone.Core/Indexers/IPTorrents/IPTorrentsSettings.cs b/src/NzbDrone.Core/Indexers/IPTorrents/IPTorrentsSettings.cs new file mode 100644 index 000000000..b694d1b6c --- /dev/null +++ b/src/NzbDrone.Core/Indexers/IPTorrents/IPTorrentsSettings.cs @@ -0,0 +1,36 @@ +using System; +using System.Linq; +using FluentValidation; +using FluentValidation.Results; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Indexers.IPTorrents +{ + public class IPTorrentsSettingsValidator : AbstractValidator + { + public IPTorrentsSettingsValidator() + { + RuleFor(c => c.Url).ValidRootUrl(); + RuleFor(c => c.Url).Matches(@"/rss\?.+;download$"); + } + } + + public class IPTorrentsSettings : IProviderConfig + { + private static readonly IPTorrentsSettingsValidator validator = new IPTorrentsSettingsValidator(); + + public IPTorrentsSettings() + { + } + + [FieldDefinition(0, Label = "Feed URL", HelpText = "The full RSS feed url generated by IPTorrents, using only the categories you selected (HD, SD, x264, etc ...)")] + public String Url { get; set; } + + public ValidationResult Validate() + { + return validator.Validate(this); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Indexers/IndexerRequest.cs b/src/NzbDrone.Core/Indexers/IndexerRequest.cs index d20c920dc..c26799f8d 100644 --- a/src/NzbDrone.Core/Indexers/IndexerRequest.cs +++ b/src/NzbDrone.Core/Indexers/IndexerRequest.cs @@ -1,7 +1,4 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; using NzbDrone.Common.Http; namespace NzbDrone.Core.Indexers @@ -25,36 +22,4 @@ namespace NzbDrone.Core.Indexers get { return HttpRequest.Url; } } } - - public class IndexerResponse - { - private readonly IndexerRequest _indexerRequest; - private readonly HttpResponse _httpResponse; - - public IndexerResponse(IndexerRequest indexerRequest, HttpResponse httpResponse) - { - _indexerRequest = indexerRequest; - _httpResponse = httpResponse; - } - - public IndexerRequest Request - { - get { return _indexerRequest; } - } - - public HttpRequest HttpRequest - { - get { return _httpResponse.Request; } - } - - public HttpResponse HttpResponse - { - get { return _httpResponse; } - } - - public String Content - { - get { return _httpResponse.Content; } - } - } } diff --git a/src/NzbDrone.Core/Indexers/IndexerResponse.cs b/src/NzbDrone.Core/Indexers/IndexerResponse.cs new file mode 100644 index 000000000..07ad347b6 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/IndexerResponse.cs @@ -0,0 +1,37 @@ +using System; +using NzbDrone.Common.Http; + +namespace NzbDrone.Core.Indexers +{ + public class IndexerResponse + { + private readonly IndexerRequest _indexerRequest; + private readonly HttpResponse _httpResponse; + + public IndexerResponse(IndexerRequest indexerRequest, HttpResponse httpResponse) + { + _indexerRequest = indexerRequest; + _httpResponse = httpResponse; + } + + public IndexerRequest Request + { + get { return _indexerRequest; } + } + + public HttpRequest HttpRequest + { + get { return _httpResponse.Request; } + } + + public HttpResponse HttpResponse + { + get { return _httpResponse; } + } + + public String Content + { + get { return _httpResponse.Content; } + } + } +} diff --git a/src/NzbDrone.Core/Indexers/KickassTorrents/KickassTorrents.cs b/src/NzbDrone.Core/Indexers/KickassTorrents/KickassTorrents.cs new file mode 100644 index 000000000..4696e0344 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/KickassTorrents/KickassTorrents.cs @@ -0,0 +1,30 @@ +using System; +using NLog; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Parser; + +namespace NzbDrone.Core.Indexers.KickassTorrents +{ + public class KickassTorrents : HttpIndexerBase + { + public override DownloadProtocol Protocol { get { return DownloadProtocol.Torrent; } } + public override Int32 PageSize { get { return 25; } } + + public KickassTorrents(IHttpClient httpClient, IConfigService configService, IParsingService parsingService, Logger logger) + : base(httpClient, configService, parsingService, logger) + { + + } + + public override IIndexerRequestGenerator GetRequestGenerator() + { + return new KickassTorrentsRequestGenerator() { Settings = Settings, PageSize = PageSize }; + } + + public override IParseIndexerResponse GetParser() + { + return new KickassTorrentsRssParser() { Settings = Settings }; + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Indexers/KickassTorrents/KickassTorrentsRequestGenerator.cs b/src/NzbDrone.Core/Indexers/KickassTorrents/KickassTorrentsRequestGenerator.cs new file mode 100644 index 000000000..47ac87ba3 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/KickassTorrents/KickassTorrentsRequestGenerator.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Common; +using NzbDrone.Common.Http; +using NzbDrone.Core.IndexerSearch.Definitions; + +namespace NzbDrone.Core.Indexers.KickassTorrents +{ + public class KickassTorrentsRequestGenerator : IIndexerRequestGenerator + { + public KickassTorrentsSettings Settings { get; set; } + + public Int32 MaxPages { get; set; } + public Int32 PageSize { get; set; } + + public KickassTorrentsRequestGenerator() + { + MaxPages = 30; + PageSize = 25; + } + + public virtual IList> GetRecentRequests() + { + var pageableRequests = new List>(); + + pageableRequests.AddIfNotNull(GetPagedRequests(1, "tv")); + + return pageableRequests; + } + + public virtual IList> GetSearchRequests(SingleEpisodeSearchCriteria searchCriteria) + { + var pageableRequests = new List>(); + + foreach (var queryTitle in searchCriteria.QueryTitles) + { + pageableRequests.AddIfNotNull(GetPagedRequests(MaxPages, "usearch", + PrepareQuery(queryTitle), + "category:tv", + String.Format("season:{0}", searchCriteria.SeasonNumber), + String.Format("episode:{0}", searchCriteria.EpisodeNumber))); + + pageableRequests.AddIfNotNull(GetPagedRequests(MaxPages, "usearch", + PrepareQuery(queryTitle), + String.Format("S{0:00}E{1:00}", searchCriteria.SeasonNumber, searchCriteria.EpisodeNumber), + "category:tv")); + } + + return pageableRequests; + } + + public virtual IList> GetSearchRequests(SeasonSearchCriteria searchCriteria) + { + var pageableRequests = new List>(); + + foreach (var queryTitle in searchCriteria.QueryTitles) + { + pageableRequests.AddIfNotNull(GetPagedRequests(MaxPages, "usearch", + PrepareQuery(queryTitle), + "category:tv", + String.Format("season:{0}", searchCriteria.SeasonNumber))); + + pageableRequests.AddIfNotNull(GetPagedRequests(MaxPages, "usearch", + PrepareQuery(queryTitle), + "category:tv", + String.Format("S{0:00}", searchCriteria.SeasonNumber))); + } + + return pageableRequests; + } + + public virtual IList> GetSearchRequests(DailyEpisodeSearchCriteria searchCriteria) + { + var pageableRequests = new List>(); + + foreach (var queryTitle in searchCriteria.QueryTitles) + { + pageableRequests.AddIfNotNull(GetPagedRequests(MaxPages, "usearch", + PrepareQuery(queryTitle), + String.Format("{0:yyyy-MM-dd}", searchCriteria.AirDate), + "category:tv")); + } + + return pageableRequests; + } + + public virtual IList> GetSearchRequests(AnimeEpisodeSearchCriteria searchCriteria) + { + return new List>(); + } + + public virtual IList> GetSearchRequests(SpecialEpisodeSearchCriteria searchCriteria) + { + var pageableRequests = new List>(); + + foreach (var queryTitle in searchCriteria.EpisodeQueryTitles) + { + pageableRequests.AddIfNotNull(GetPagedRequests(MaxPages, "usearch", + PrepareQuery(queryTitle), + "category:tv")); + } + + return pageableRequests; + } + + private IEnumerable GetPagedRequests(Int32 maxPages, String rssType, params String[] searchParameters) + { + String searchUrl = null; + + if (searchParameters.Any()) + { + // Prevent adding a '/' if no search parameters are specified + if (Settings.VerifiedOnly) + { + searchUrl = String.Format("/{0} verified:1", String.Join(" ", searchParameters)); + } + else + { + searchUrl = String.Format("/{0}", String.Join(" ", searchParameters)).Trim(); + } + } + + if (PageSize == 0) + { + var request = new IndexerRequest(String.Format("{0}/{1}{2}/?rss=1&field=time_add&sorder=desc", Settings.BaseUrl.TrimEnd('/'), rssType, searchUrl), HttpAccept.Rss); + request.HttpRequest.SuppressHttpError = true; + + yield return request; + } + else + { + for (var page = 0; page < maxPages; page++) + { + var request = new IndexerRequest(String.Format("{0}/{1}{2}/{3}/?rss=1&field=time_add&sorder=desc", Settings.BaseUrl.TrimEnd('/'), rssType, searchUrl, page + 1), HttpAccept.Rss); + request.HttpRequest.SuppressHttpError = true; + + yield return request; + } + } + } + + private String PrepareQuery(String query) + { + return query.Replace('+', ' '); + } + } +} diff --git a/src/NzbDrone.Core/Indexers/KickassTorrents/KickassTorrentsRssParser.cs b/src/NzbDrone.Core/Indexers/KickassTorrents/KickassTorrentsRssParser.cs new file mode 100644 index 000000000..b2b8392f7 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/KickassTorrents/KickassTorrentsRssParser.cs @@ -0,0 +1,39 @@ +using System; +using System.Linq; +using System.Xml.Linq; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.Indexers.KickassTorrents +{ + public class KickassTorrentsRssParser : EzrssTorrentRssParser + { + public KickassTorrentsSettings Settings { get; set; } + + public KickassTorrentsRssParser() + { + + } + + protected override bool PreProcess(IndexerResponse indexerResponse) + { + if (indexerResponse.HttpResponse.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return false; + } + + return base.PreProcess(indexerResponse); + } + + protected override ReleaseInfo PostProcess(XElement item, ReleaseInfo releaseInfo) + { + var verified = GetEzrssElement(item, "verified"); + + if (Settings != null && Settings.VerifiedOnly && (string)verified == "0") + { + return null; + } + + return base.PostProcess(item, releaseInfo); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Indexers/KickassTorrents/KickassTorrentsSettings.cs b/src/NzbDrone.Core/Indexers/KickassTorrents/KickassTorrentsSettings.cs new file mode 100644 index 000000000..a50a88636 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/KickassTorrents/KickassTorrentsSettings.cs @@ -0,0 +1,40 @@ +using System; +using System.Linq; +using FluentValidation; +using FluentValidation.Results; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Indexers.KickassTorrents +{ + public class KickassTorrentsSettingsValidator : AbstractValidator + { + public KickassTorrentsSettingsValidator() + { + RuleFor(c => c.BaseUrl).ValidRootUrl(); + } + } + + public class KickassTorrentsSettings : IProviderConfig + { + private static readonly KickassTorrentsSettingsValidator validator = new KickassTorrentsSettingsValidator(); + + public KickassTorrentsSettings() + { + BaseUrl = "http://kickass.so"; + VerifiedOnly = true; + } + + [FieldDefinition(0, Label = "Website URL")] + public String BaseUrl { get; set; } + + [FieldDefinition(1, Label = "Verified Only", Type = FieldType.Checkbox, Advanced = true, HelpText = "By setting this to No you will likely get more junk and unconfirmed releases, so use it with caution.")] + public Boolean VerifiedOnly { get; set; } + + public ValidationResult Validate() + { + return validator.Validate(this); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Indexers/Nyaa/Nyaa.cs b/src/NzbDrone.Core/Indexers/Nyaa/Nyaa.cs new file mode 100644 index 000000000..0f86fb77e --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Nyaa/Nyaa.cs @@ -0,0 +1,32 @@ +using System; +using System.Linq; +using System.Collections.Generic; +using NLog; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Parser; + +namespace NzbDrone.Core.Indexers.Nyaa +{ + public class Nyaa : HttpIndexerBase + { + public override DownloadProtocol Protocol { get { return DownloadProtocol.Torrent; } } + public override Int32 PageSize { get { return 100; } } + + public Nyaa(IHttpClient httpClient, IConfigService configService, IParsingService parsingService, Logger logger) + : base(httpClient, configService, parsingService, logger) + { + + } + + public override IIndexerRequestGenerator GetRequestGenerator() + { + return new NyaaRequestGenerator() { Settings = Settings, PageSize = PageSize }; + } + + public override IParseIndexerResponse GetParser() + { + return new TorrentRssParser() { UseGuidInfoUrl = true, ParseSizeInDescription = true, ParseSeedersInDescription = true }; + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Indexers/Nyaa/NyaaRequestGenerator.cs b/src/NzbDrone.Core/Indexers/Nyaa/NyaaRequestGenerator.cs new file mode 100644 index 000000000..b1e391482 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Nyaa/NyaaRequestGenerator.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using NzbDrone.Common; +using NzbDrone.Common.Http; +using NzbDrone.Core.IndexerSearch.Definitions; + +namespace NzbDrone.Core.Indexers.Nyaa +{ + public class NyaaRequestGenerator : IIndexerRequestGenerator + { + public NyaaSettings Settings { get; set; } + + public Int32 MaxPages { get; set; } + public Int32 PageSize { get; set; } + + public NyaaRequestGenerator() + { + MaxPages = 30; + PageSize = 100; + } + + public virtual IList> GetRecentRequests() + { + var pageableRequests = new List>(); + + pageableRequests.AddIfNotNull(GetPagedRequests(1, null)); + + return pageableRequests; + } + + public virtual IList> GetSearchRequests(SingleEpisodeSearchCriteria searchCriteria) + { + return new List>(); + } + + public virtual IList> GetSearchRequests(SeasonSearchCriteria searchCriteria) + { + return new List>(); + } + + public virtual IList> GetSearchRequests(DailyEpisodeSearchCriteria searchCriteria) + { + return new List>(); + } + + public virtual IList> GetSearchRequests(AnimeEpisodeSearchCriteria searchCriteria) + { + var pageableRequests = new List>(); + + foreach (var queryTitle in searchCriteria.QueryTitles) + { + var searchTitle = PrepareQuery(queryTitle); + + pageableRequests.AddIfNotNull(GetPagedRequests(MaxPages, + String.Format("&term={0}+{1:0}", + searchTitle, + searchCriteria.AbsoluteEpisodeNumber))); + + if (searchCriteria.AbsoluteEpisodeNumber < 10) + { + pageableRequests.AddIfNotNull(GetPagedRequests(MaxPages, + String.Format("&term={0}+{1:00}", + searchTitle, + searchCriteria.AbsoluteEpisodeNumber))); + } + } + + return pageableRequests; + } + + public virtual IList> GetSearchRequests(SpecialEpisodeSearchCriteria searchCriteria) + { + var pageableRequests = new List>(); + + foreach (var queryTitle in searchCriteria.EpisodeQueryTitles) + { + pageableRequests.AddIfNotNull(GetPagedRequests(MaxPages, + String.Format("&term={0}", + PrepareQuery(queryTitle)))); + } + + return pageableRequests; + } + + private IEnumerable GetPagedRequests(Int32 maxPages, String searchParameters) + { + var baseUrl = String.Format("{0}/?page=rss&cats=1_37&filter=1", Settings.BaseUrl.TrimEnd('/')); + + if (PageSize == 0) + { + yield return new IndexerRequest(String.Format("{0}{1}", baseUrl, searchParameters), HttpAccept.Rss); + } + else + { + for (var page = 0; page < maxPages; page++) + { + yield return new IndexerRequest(String.Format("{0}&offset={1}{2}", baseUrl, page + 1, searchParameters), HttpAccept.Rss); + } + } + } + + private String PrepareQuery(String query) + { + return query.Replace(' ', '+'); + } + } +} diff --git a/src/NzbDrone.Core/Indexers/Nyaa/NyaaSettings.cs b/src/NzbDrone.Core/Indexers/Nyaa/NyaaSettings.cs new file mode 100644 index 000000000..10724cb53 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Nyaa/NyaaSettings.cs @@ -0,0 +1,36 @@ +using System; +using System.Linq; +using FluentValidation; +using FluentValidation.Results; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Indexers.Nyaa +{ + public class NyaaSettingsValidator : AbstractValidator + { + public NyaaSettingsValidator() + { + RuleFor(c => c.BaseUrl).ValidRootUrl(); + } + } + + public class NyaaSettings : IProviderConfig + { + private static readonly NyaaSettingsValidator validator = new NyaaSettingsValidator(); + + public NyaaSettings() + { + BaseUrl = "http://www.nyaa.se"; + } + + [FieldDefinition(0, Label = "Website URL")] + public String BaseUrl { get; set; } + + public ValidationResult Validate() + { + return validator.Validate(this); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Indexers/RssParser.cs b/src/NzbDrone.Core/Indexers/RssParser.cs index 9c4b77c24..42aff6e8b 100644 --- a/src/NzbDrone.Core/Indexers/RssParser.cs +++ b/src/NzbDrone.Core/Indexers/RssParser.cs @@ -75,7 +75,13 @@ namespace NzbDrone.Core.Indexers { if (indexerResponse.HttpResponse.StatusCode != System.Net.HttpStatusCode.OK) { - throw new IndexerException("Indexer API call resulted in an unexpected StatusCode [{0}]", indexerResponse.HttpResponse.StatusCode); + throw new IndexerException(indexerResponse, "Indexer API call resulted in an unexpected StatusCode [{0}]", indexerResponse.HttpResponse.StatusCode); + } + + if (indexerResponse.HttpResponse.Headers.ContentType != null && indexerResponse.HttpResponse.Headers.ContentType.Contains("text/html") && + indexerResponse.HttpRequest.Headers.Accept != null && !indexerResponse.HttpRequest.Headers.Accept.Contains("text/html")) + { + throw new IndexerException(indexerResponse, "Indexer responded with html content. Site is likely blocked or unavailable."); } return true; diff --git a/src/NzbDrone.Core/Indexers/TorrentRssParser.cs b/src/NzbDrone.Core/Indexers/TorrentRssParser.cs new file mode 100644 index 000000000..37d4d1ae1 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/TorrentRssParser.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.Indexers +{ + public class TorrentRssParser : RssParser + { + // Parse various seeder/leecher/peers formats in the description element to determine number of seeders. + public Boolean ParseSeedersInDescription { get; set; } + + public TorrentRssParser() + { + + } + + protected override ReleaseInfo CreateNewReleaseInfo() + { + return new TorrentInfo(); + } + + protected override ReleaseInfo ProcessItem(XElement item, ReleaseInfo releaseInfo) + { + var result = base.ProcessItem(item, releaseInfo) as TorrentInfo; + + result.InfoHash = GetInfoHash(item); + result.MagnetUrl = GetMagnetUrl(item); + result.Seeds = GetSeeders(item); + result.Peers = GetPeers(item); + + return result; + } + + protected virtual String GetInfoHash(XElement item) + { + return null; + } + + protected virtual String GetMagnetUrl(XElement item) + { + return null; + } + + protected virtual Int32? GetSeeders(XElement item) + { + if (ParseSeedersInDescription) + { + var match = ParseSeedersRegex.Match(item.Element("description").Value); + + if (match.Success) + { + return Int32.Parse(match.Groups["value"].Value); + } + } + + return null; + } + + protected virtual Int32? GetPeers(XElement item) + { + if (ParseSeedersInDescription) + { + var match = ParsePeersRegex.Match(item.Element("description").Value); + + if (match.Success) + { + return Int32.Parse(match.Groups["value"].Value); + } + } + + return null; + } + + private static readonly Regex ParseSeedersRegex = new Regex(@"(Seeder)s?:\s+(?\d+)|(?\d+)\s+(seeder)s?", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex ParsePeersRegex = new Regex(@"(Leecher|Peer)s?:\s+(?\d+)|(?\d+)\s+(leecher|peer)s?", RegexOptions.IgnoreCase | RegexOptions.Compiled); + } +} diff --git a/src/NzbDrone.Core/Indexers/Torrentleech/Torrentleech.cs b/src/NzbDrone.Core/Indexers/Torrentleech/Torrentleech.cs new file mode 100644 index 000000000..894395c29 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Torrentleech/Torrentleech.cs @@ -0,0 +1,34 @@ +using FluentValidation.Results; +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Parser; +using NLog; + +namespace NzbDrone.Core.Indexers.Torrentleech +{ + public class Torrentleech : HttpIndexerBase + { + public override DownloadProtocol Protocol { get { return DownloadProtocol.Torrent; } } + public override Boolean SupportsSearch { get { return false; } } + public override Int32 PageSize { get { return 0; } } + + public Torrentleech(IHttpClient httpClient, IConfigService configService, IParsingService parsingService, Logger logger) + : base(httpClient, configService, parsingService, logger) + { + + } + + public override IIndexerRequestGenerator GetRequestGenerator() + { + return new TorrentleechRequestGenerator() { Settings = Settings }; + } + + public override IParseIndexerResponse GetParser() + { + return new TorrentRssParser() { UseGuidInfoUrl = true, ParseSeedersInDescription = true }; + } + } +} diff --git a/src/NzbDrone.Core/Indexers/Torrentleech/TorrentleechRequestGenerator.cs b/src/NzbDrone.Core/Indexers/Torrentleech/TorrentleechRequestGenerator.cs new file mode 100644 index 000000000..4d8a73e01 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Torrentleech/TorrentleechRequestGenerator.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using NzbDrone.Common; +using NzbDrone.Common.Http; +using NzbDrone.Core.IndexerSearch.Definitions; + +namespace NzbDrone.Core.Indexers.Torrentleech +{ + public class TorrentleechRequestGenerator : IIndexerRequestGenerator + { + public TorrentleechSettings Settings { get; set; } + + public virtual IList> GetRecentRequests() + { + var pageableRequests = new List>(); + + pageableRequests.AddIfNotNull(GetRssRequests(null)); + + return pageableRequests; + } + + public virtual IList> GetSearchRequests(SingleEpisodeSearchCriteria searchCriteria) + { + return new List>(); + } + + public virtual IList> GetSearchRequests(SeasonSearchCriteria searchCriteria) + { + return new List>(); + } + + public virtual IList> GetSearchRequests(DailyEpisodeSearchCriteria searchCriteria) + { + return new List>(); + } + + public virtual IList> GetSearchRequests(AnimeEpisodeSearchCriteria searchCriteria) + { + return new List>(); + } + + public virtual IList> GetSearchRequests(SpecialEpisodeSearchCriteria searchCriteria) + { + return new List>(); + } + + private IEnumerable GetRssRequests(String searchParameters) + { + yield return new IndexerRequest(String.Format("{0}/{1}{2}", Settings.BaseUrl.Trim().TrimEnd('/'), Settings.ApiKey, searchParameters), HttpAccept.Rss); + } + } +} diff --git a/src/NzbDrone.Core/Indexers/Torrentleech/TorrentleechSettings.cs b/src/NzbDrone.Core/Indexers/Torrentleech/TorrentleechSettings.cs new file mode 100644 index 000000000..242ad9335 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Torrentleech/TorrentleechSettings.cs @@ -0,0 +1,40 @@ +using System; +using System.Linq; +using FluentValidation; +using FluentValidation.Results; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Indexers.Torrentleech +{ + public class TorrentleechSettingsValidator : AbstractValidator + { + public TorrentleechSettingsValidator() + { + RuleFor(c => c.BaseUrl).ValidRootUrl(); + RuleFor(c => c.ApiKey).NotEmpty(); + } + } + + public class TorrentleechSettings : IProviderConfig + { + private static readonly TorrentleechSettingsValidator validator = new TorrentleechSettingsValidator(); + + public TorrentleechSettings() + { + BaseUrl = "http://rss.torrentleech.org"; + } + + [FieldDefinition(0, Label = "Website URL")] + public String BaseUrl { get; set; } + + [FieldDefinition(1, Label = "API Key")] + public String ApiKey { get; set; } + + public ValidationResult Validate() + { + return validator.Validate(this); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/MediaFiles/DownloadedEpisodesImportService.cs b/src/NzbDrone.Core/MediaFiles/DownloadedEpisodesImportService.cs index aa31febb9..5ea90d122 100644 --- a/src/NzbDrone.Core/MediaFiles/DownloadedEpisodesImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/DownloadedEpisodesImportService.cs @@ -100,15 +100,18 @@ namespace NzbDrone.Core.MediaFiles var videoFiles = _diskScanService.GetVideoFiles(directoryInfo.FullName); - foreach (var videoFile in videoFiles) + if (downloadClientItem == null) { - if (_diskProvider.IsFileLocked(videoFile)) + foreach (var videoFile in videoFiles) { - _logger.Debug("[{0}] is currently locked by another process, skipping", videoFile); - return new List - { - new ImportResult(new ImportDecision(new LocalEpisode { Path = videoFile }, "Locked file, try again later"), "Locked file, try again later") - }; + if (_diskProvider.IsFileLocked(videoFile)) + { + _logger.Debug("[{0}] is currently locked by another process, skipping", videoFile); + return new List + { + new ImportResult(new ImportDecision(new LocalEpisode { Path = videoFile }, "Locked file, try again later"), "Locked file, try again later") + }; + } } } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeFileMovingService.cs b/src/NzbDrone.Core/MediaFiles/EpisodeFileMovingService.cs index 0f0d39d98..beefa21ff 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeFileMovingService.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeFileMovingService.cs @@ -28,6 +28,7 @@ namespace NzbDrone.Core.MediaFiles private readonly IBuildFileNames _buildFileNames; private readonly IDiskProvider _diskProvider; private readonly IMediaFileAttributeService _mediaFileAttributeService; + private readonly IConfigService _configService; private readonly Logger _logger; public EpisodeFileMovingService(IEpisodeService episodeService, @@ -35,6 +36,7 @@ namespace NzbDrone.Core.MediaFiles IBuildFileNames buildFileNames, IDiskProvider diskProvider, IMediaFileAttributeService mediaFileAttributeService, + IConfigService configService, Logger logger) { _episodeService = episodeService; @@ -42,6 +44,7 @@ namespace NzbDrone.Core.MediaFiles _buildFileNames = buildFileNames; _diskProvider = diskProvider; _mediaFileAttributeService = mediaFileAttributeService; + _configService = configService; _logger = logger; } @@ -53,7 +56,7 @@ namespace NzbDrone.Core.MediaFiles _logger.Debug("Renaming episode file: {0} to {1}", episodeFile, filePath); - return TransferFile(episodeFile, series, episodes, filePath, false); + return TransferFile(episodeFile, series, episodes, filePath, TransferMode.Move); } public EpisodeFile MoveEpisodeFile(EpisodeFile episodeFile, LocalEpisode localEpisode) @@ -63,7 +66,7 @@ namespace NzbDrone.Core.MediaFiles _logger.Debug("Moving episode file: {0} to {1}", episodeFile, filePath); - return TransferFile(episodeFile, localEpisode.Series, localEpisode.Episodes, filePath, false); + return TransferFile(episodeFile, localEpisode.Series, localEpisode.Episodes, filePath, TransferMode.Move); } public EpisodeFile CopyEpisodeFile(EpisodeFile episodeFile, LocalEpisode localEpisode) @@ -73,10 +76,17 @@ namespace NzbDrone.Core.MediaFiles _logger.Debug("Copying episode file: {0} to {1}", episodeFile, filePath); - return TransferFile(episodeFile, localEpisode.Series, localEpisode.Episodes, filePath, true); + if (_configService.CopyUsingHardlinks) + { + return TransferFile(episodeFile, localEpisode.Series, localEpisode.Episodes, filePath, TransferMode.HardLinkOrCopy); + } + else + { + return TransferFile(episodeFile, localEpisode.Series, localEpisode.Episodes, filePath, TransferMode.Copy); + } } - private EpisodeFile TransferFile(EpisodeFile episodeFile, Series series, List episodes, String destinationFilename, Boolean copyOnly) + private EpisodeFile TransferFile(EpisodeFile episodeFile, Series series, List episodes, string destinationFilename, TransferMode mode) { Ensure.That(episodeFile, () => episodeFile).IsNotNull(); Ensure.That(series,() => series).IsNotNull(); @@ -115,16 +125,8 @@ namespace NzbDrone.Core.MediaFiles } } - if (copyOnly) - { - _logger.Debug("Copying [{0}] > [{1}]", episodeFilePath, destinationFilename); - _diskProvider.CopyFile(episodeFilePath, destinationFilename); - } - else - { - _logger.Debug("Moving [{0}] > [{1}]", episodeFilePath, destinationFilename); - _diskProvider.MoveFile(episodeFilePath, destinationFilename); - } + _logger.Debug("{0} [{1}] > [{2}]", mode, episodeFilePath, destinationFilename); + _diskProvider.TransferFile(episodeFilePath, destinationFilename, mode); episodeFile.RelativePath = series.Path.GetRelativePath(destinationFilename); diff --git a/src/NzbDrone.Core/MediaFiles/TorrentInfo/TorrentFileInfoReader.cs b/src/NzbDrone.Core/MediaFiles/TorrentInfo/TorrentFileInfoReader.cs new file mode 100644 index 000000000..2e8f12758 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TorrentInfo/TorrentFileInfoReader.cs @@ -0,0 +1,18 @@ +using System; +using MonoTorrent; + +namespace NzbDrone.Core.MediaFiles.TorrentInfo +{ + public interface ITorrentFileInfoReader + { + String GetHashFromTorrentFile(Byte[] fileContents); + } + + public class TorrentFileInfoReader: ITorrentFileInfoReader + { + public String GetHashFromTorrentFile(byte[] fileContents) + { + return Torrent.Load(fileContents).InfoHash.ToHex(); + } + } +} diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index fd11dd1bd..d969951c7 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -121,9 +121,7 @@ - - Code - + @@ -150,6 +148,7 @@ + @@ -187,9 +186,7 @@ - - Code - + @@ -206,9 +203,7 @@ - - Code - + @@ -267,9 +262,7 @@ - - Code - + @@ -280,10 +273,22 @@ + + + + + + + + + + + + @@ -308,9 +313,7 @@ - - Code - + @@ -321,11 +324,30 @@ + + + + + + + + + + + + + + + + + + + @@ -336,15 +358,11 @@ - - Code - + - - Code - + @@ -376,9 +394,7 @@ - - Code - + @@ -395,10 +411,24 @@ + + + + + + + + + + + + + + @@ -406,20 +436,29 @@ - - Code - + + + + + + + + + + + + @@ -429,6 +468,10 @@ + + + + @@ -580,6 +623,7 @@ + Code @@ -834,6 +878,10 @@ {F6FC6BE7-0847-4817-A1ED-223DC647C3D7} Marr.Data + + {411a9e0e-fdc6-4e25-828a-0c2cd1cd96f8} + MonoTorrent + {F2BE0FDF-6E47-4827-A420-DD4EF82407F8} NzbDrone.Common diff --git a/src/NzbDrone.Core/Parser/Model/TorrentInfo.cs b/src/NzbDrone.Core/Parser/Model/TorrentInfo.cs index 434967b2b..31520042f 100644 --- a/src/NzbDrone.Core/Parser/Model/TorrentInfo.cs +++ b/src/NzbDrone.Core/Parser/Model/TorrentInfo.cs @@ -1,8 +1,26 @@ +using System; + namespace NzbDrone.Core.Parser.Model { public class TorrentInfo : ReleaseInfo { public string MagnetUrl { get; set; } public string InfoHash { get; set; } + public Int32? Seeds { get; set; } + public Int32? Peers { get; set; } + + public static Int32? GetSeeders(ReleaseInfo release) + { + var torrentInfo = release as TorrentInfo; + + if (torrentInfo == null) + { + return null; + } + else + { + return torrentInfo.Seeds; + } + } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Parser/Parser.cs b/src/NzbDrone.Core/Parser/Parser.cs index 1eb7bb1ed..13a5a468e 100644 --- a/src/NzbDrone.Core/Parser/Parser.cs +++ b/src/NzbDrone.Core/Parser/Parser.cs @@ -138,9 +138,15 @@ namespace NzbDrone.Core.Parser private static readonly Regex NormalizeRegex = new Regex(@"((?:\b|_)(?|\?|\*|\:|\||848x480|1280x720|1920x1080|8bit|10bit", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex WebsitePrefixRegex = new Regex(@"^\[\s*[a-z]+(\.[a-z]+)+\s*\][- ]*", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex AirDateRegex = new Regex(@"^(.*?)(?\d{4})[_.-](?[0-1][0-9])[_.-](?[0-3][0-9])|(?[0-1][0-9])[_.-](?[0-3][0-9])[_.-](?\d{4}))(?!\d)", RegexOptions.IgnoreCase | RegexOptions.Compiled); @@ -214,6 +220,9 @@ namespace NzbDrone.Core.Parser var simpleTitle = SimpleTitleRegex.Replace(title, String.Empty); + // TODO: Quick fix stripping [url] - prefixes. + simpleTitle = WebsitePrefixRegex.Replace(simpleTitle, String.Empty); + var airDateMatch = AirDateRegex.Match(simpleTitle); if (airDateMatch.Success) { @@ -365,14 +374,18 @@ namespace NzbDrone.Core.Parser public static string RemoveFileExtension(string title) { - if (!title.ContainsInvalidPathChars()) - { - var extension = Path.GetExtension(title).ToLower(); - if (MediaFiles.MediaFileExtensions.Extensions.Contains(extension) || new [] { ".par2", ".nzb" }.Contains(extension)) + title = FileExtensionRegex.Replace(title, m => { - title = Path.Combine(Path.GetDirectoryName(title), Path.GetFileNameWithoutExtension(title)); - } - } + var extension = m.Value.ToLower(); + if (MediaFiles.MediaFileExtensions.Extensions.Contains(extension) || new[] { ".par2", ".nzb" }.Contains(extension)) + { + return String.Empty; + } + else + { + return m.Value; + } + }); return title; } diff --git a/src/NzbDrone.Core/RemotePathMappings/RemotePathMapping.cs b/src/NzbDrone.Core/RemotePathMappings/RemotePathMapping.cs index 17b2c8692..e2cdc4c17 100644 --- a/src/NzbDrone.Core/RemotePathMappings/RemotePathMapping.cs +++ b/src/NzbDrone.Core/RemotePathMappings/RemotePathMapping.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using NzbDrone.Common.Disk; using NzbDrone.Core.Datastore; diff --git a/src/NzbDrone.Core/RemotePathMappings/RemotePathMappingService.cs b/src/NzbDrone.Core/RemotePathMappings/RemotePathMappingService.cs index d993220e2..7a69a1b7f 100644 --- a/src/NzbDrone.Core/RemotePathMappings/RemotePathMappingService.cs +++ b/src/NzbDrone.Core/RemotePathMappings/RemotePathMappingService.cs @@ -22,11 +22,11 @@ namespace NzbDrone.Core.RemotePathMappings RemotePathMapping Get(int id); RemotePathMapping Update(RemotePathMapping mapping); - String RemapRemoteToLocal(String host, String remotePath); - String RemapLocalToRemote(String host, String localPath); + OsPath RemapRemoteToLocal(String host, OsPath remotePath); + OsPath RemapLocalToRemote(String host, OsPath localPath); // TODO: Remove around January 2015. Used to migrate legacy Local Category Path settings. - void MigrateLocalCategoryPath(Int32 downloadClientId, IProviderConfig newSettings, String host, String remotePath, String localPath); + void MigrateLocalCategoryPath(Int32 downloadClientId, IProviderConfig newSettings, String host, OsPath remotePath, OsPath localPath); } public class RemotePathMappingService : IRemotePathMappingService @@ -60,8 +60,8 @@ namespace NzbDrone.Core.RemotePathMappings public RemotePathMapping Add(RemotePathMapping mapping) { - mapping.LocalPath = CleanPath(mapping.LocalPath); - mapping.RemotePath = CleanPath(mapping.RemotePath); + mapping.LocalPath = new OsPath(mapping.LocalPath).AsDirectory().FullPath; + mapping.RemotePath = new OsPath(mapping.RemotePath).AsDirectory().FullPath; var all = All(); @@ -106,17 +106,20 @@ namespace NzbDrone.Core.RemotePathMappings throw new ArgumentException("Invalid Host"); } - if (mapping.RemotePath.IsNullOrWhiteSpace()) + var remotePath = new OsPath(mapping.RemotePath); + var localPath = new OsPath(mapping.LocalPath); + + if (remotePath.IsEmpty) { throw new ArgumentException("Invalid RemotePath"); } - if (mapping.LocalPath.IsNullOrWhiteSpace() || !Path.IsPathRooted(mapping.LocalPath)) + if (localPath.IsEmpty || !localPath.IsRooted) { throw new ArgumentException("Invalid LocalPath"); } - if (!_diskProvider.FolderExists(mapping.LocalPath)) + if (!_diskProvider.FolderExists(localPath.FullPath)) { throw new DirectoryNotFoundException("Can't add mount point directory that doesn't exist."); } @@ -127,27 +130,18 @@ namespace NzbDrone.Core.RemotePathMappings } } - public String RemapRemoteToLocal(String host, String remotePath) + public OsPath RemapRemoteToLocal(String host, OsPath remotePath) { - if (remotePath.IsNullOrWhiteSpace()) + if (remotePath.IsEmpty) { return remotePath; } - var cleanRemotePath = CleanPath(remotePath); - foreach (var mapping in All()) { - if (host == mapping.Host && cleanRemotePath.StartsWith(mapping.RemotePath)) + if (host == mapping.Host && new OsPath(mapping.RemotePath).Contains(remotePath)) { - var localPath = mapping.LocalPath + cleanRemotePath.Substring(mapping.RemotePath.Length); - - localPath = localPath.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); - - if (!remotePath.EndsWith("/") && !remotePath.EndsWith("\\")) - { - localPath = localPath.TrimEnd('/', '\\'); - } + var localPath = new OsPath(mapping.LocalPath) + (remotePath - new OsPath(mapping.RemotePath)); return localPath; } @@ -156,29 +150,18 @@ namespace NzbDrone.Core.RemotePathMappings return remotePath; } - public String RemapLocalToRemote(String host, String localPath) + public OsPath RemapLocalToRemote(String host, OsPath localPath) { - if (localPath.IsNullOrWhiteSpace()) + if (localPath.IsEmpty) { return localPath; } - var cleanLocalPath = CleanPath(localPath); - foreach (var mapping in All()) { - if (host != mapping.Host) continue; - - if (cleanLocalPath.StartsWith(mapping.LocalPath)) + if (host == mapping.Host && new OsPath(mapping.LocalPath).Contains(localPath)) { - var remotePath = mapping.RemotePath + cleanLocalPath.Substring(mapping.LocalPath.Length); - - remotePath = remotePath.Replace(Path.DirectorySeparatorChar, mapping.RemotePath.Contains('\\') ? '\\' : '/'); - - if (!localPath.EndsWith("/") && !localPath.EndsWith("\\")) - { - remotePath = remotePath.TrimEnd('/', '\\'); - } + var remotePath = new OsPath(mapping.RemotePath) + (localPath - new OsPath(mapping.LocalPath)); return remotePath; } @@ -188,35 +171,20 @@ namespace NzbDrone.Core.RemotePathMappings } // TODO: Remove around January 2015. Used to migrate legacy Local Category Path settings. - public void MigrateLocalCategoryPath(Int32 downloadClientId, IProviderConfig newSettings, String host, String remotePath, String localPath) + public void MigrateLocalCategoryPath(Int32 downloadClientId, IProviderConfig newSettings, String host, OsPath remotePath, OsPath localPath) { _logger.Debug("Migrating local category path for Host {0}/{1} to {2}", host, remotePath, localPath); var existingMappings = All().Where(v => v.Host == host).ToList(); - remotePath = CleanPath(remotePath); - localPath = CleanPath(localPath); - - if (!existingMappings.Any(v => v.LocalPath == localPath && v.RemotePath == remotePath)) + if (!existingMappings.Any(v => new OsPath(v.LocalPath) == localPath && new OsPath(v.RemotePath) == remotePath)) { - Add(new RemotePathMapping { Host = host, RemotePath = remotePath, LocalPath = localPath }); + Add(new RemotePathMapping { Host = host, RemotePath = remotePath.FullPath, LocalPath = localPath.FullPath }); } var downloadClient = _downloadClientRepository.Get(downloadClientId); downloadClient.Settings = newSettings; _downloadClientRepository.Update(downloadClient); } - - private static String CleanPath(String path) - { - if (path.Contains('\\')) - { - return path.TrimEnd('\\', '/') + "\\"; - } - else - { - return path.TrimEnd('\\', '/') + "/"; - } - } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/Rest/RestSharpExtensions.cs b/src/NzbDrone.Core/Rest/RestSharpExtensions.cs index 50bf89d2d..a1ffc44de 100644 --- a/src/NzbDrone.Core/Rest/RestSharpExtensions.cs +++ b/src/NzbDrone.Core/Rest/RestSharpExtensions.cs @@ -1,4 +1,6 @@ -using System.Net; +using System; +using System.Net; +using System.Linq; using NLog; using NzbDrone.Common.EnsureThat; using NzbDrone.Common.Instrumentation; @@ -71,5 +73,14 @@ namespace NzbDrone.Core.Rest { request.AddParameter(name, value.ToString(), ParameterType.GetOrPost); } + + public static object GetHeaderValue(this IRestResponse response, String key) + { + var header = response.Headers.FirstOrDefault(v => v.Name == key); + + if (header == null) return null; + + return header.Value; + } } } \ No newline at end of file diff --git a/src/NzbDrone.Mono/DiskProvider.cs b/src/NzbDrone.Mono/DiskProvider.cs index 0eac87104..5610197a3 100644 --- a/src/NzbDrone.Mono/DiskProvider.cs +++ b/src/NzbDrone.Mono/DiskProvider.cs @@ -7,6 +7,7 @@ using NzbDrone.Common; using NzbDrone.Common.Disk; using NzbDrone.Common.EnsureThat; using NzbDrone.Common.Instrumentation; +using Mono.Unix; namespace NzbDrone.Mono { @@ -151,5 +152,18 @@ namespace NzbDrone.Mono .OrderByDescending(drive => drive.Name.Length) .FirstOrDefault(); } + + public override bool TryCreateHardLink(string source, string destination) + { + try + { + UnixFileSystemInfo.GetFileSystemEntry(source).CreateLink(destination); + return true; + } + catch + { + return false; + } + } } } diff --git a/src/NzbDrone.Windows/DiskProvider.cs b/src/NzbDrone.Windows/DiskProvider.cs index db6a02304..f4c13c344 100644 --- a/src/NzbDrone.Windows/DiskProvider.cs +++ b/src/NzbDrone.Windows/DiskProvider.cs @@ -19,6 +19,10 @@ namespace NzbDrone.Windows out ulong lpTotalNumberOfBytes, out ulong lpTotalNumberOfFreeBytes); + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] + [return: MarshalAs(UnmanagedType.Bool)] + static extern bool CreateHardLink(string lpFileName, string lpExistingFileName, IntPtr lpSecurityAttributes); + public override long? GetAvailableSpace(string path) { Ensure.That(path, () => path).IsValidPath(); @@ -98,5 +102,18 @@ namespace NzbDrone.Windows return 0; } + + + public override bool TryCreateHardLink(string source, string destination) + { + try + { + return CreateHardLink(destination, source, IntPtr.Zero); + } + catch + { + return false; + } + } } } diff --git a/src/NzbDrone.sln b/src/NzbDrone.sln index cf64337d3..3c756919d 100644 --- a/src/NzbDrone.sln +++ b/src/NzbDrone.sln @@ -76,6 +76,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Windows.Test", "Nz EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Mono.Test", "NzbDrone.Mono.Test\NzbDrone.Mono.Test.csproj", "{40D72824-7D02-4A77-9106-8FE0EEA2B997}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MonoTorrent", "MonoTorrent\MonoTorrent.csproj", "{411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -471,6 +473,21 @@ Global {40D72824-7D02-4A77-9106-8FE0EEA2B997}.Release|Mixed Platforms.Build.0 = Release|x86 {40D72824-7D02-4A77-9106-8FE0EEA2B997}.Release|x86.ActiveCfg = Release|x86 {40D72824-7D02-4A77-9106-8FE0EEA2B997}.Release|x86.Build.0 = Release|x86 + {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}.Debug|Any CPU.ActiveCfg = Debug|x86 + {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}.Debug|Mixed Platforms.ActiveCfg = Debug|x86 + {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}.Debug|Mixed Platforms.Build.0 = Debug|x86 + {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}.Debug|x86.ActiveCfg = Debug|x86 + {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}.Debug|x86.Build.0 = Debug|x86 + {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}.Mono|Any CPU.ActiveCfg = Release|x86 + {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}.Mono|Mixed Platforms.ActiveCfg = Release|x86 + {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}.Mono|Mixed Platforms.Build.0 = Release|x86 + {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}.Mono|x86.ActiveCfg = Release|x86 + {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}.Mono|x86.Build.0 = Release|x86 + {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}.Release|Any CPU.ActiveCfg = Release|x86 + {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}.Release|Mixed Platforms.ActiveCfg = Release|x86 + {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}.Release|Mixed Platforms.Build.0 = Release|x86 + {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}.Release|x86.ActiveCfg = Release|x86 + {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}.Release|x86.Build.0 = Release|x86 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -500,6 +517,7 @@ Global {4EACDBBC-BCD7-4765-A57B-3E08331E4749} = {57A04B72-8088-4F75-A582-1158CF8291F7} {80B51429-7A0E-46D6-BEE3-C80DCB1C4EAA} = {4EACDBBC-BCD7-4765-A57B-3E08331E4749} {40D72824-7D02-4A77-9106-8FE0EEA2B997} = {4EACDBBC-BCD7-4765-A57B-3E08331E4749} + {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8} = {F6E3A728-AE77-4D02-BAC8-82FBC1402DDA} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution EnterpriseLibraryConfigurationToolBinariesPath = packages\Unity.2.1.505.0\lib\NET35;packages\Unity.2.1.505.2\lib\NET35 diff --git a/src/UI/Settings/MediaManagement/Sorting/SortingViewTemplate.hbs b/src/UI/Settings/MediaManagement/Sorting/SortingViewTemplate.hbs index c137a1de3..55587059c 100644 --- a/src/UI/Settings/MediaManagement/Sorting/SortingViewTemplate.hbs +++ b/src/UI/Settings/MediaManagement/Sorting/SortingViewTemplate.hbs @@ -25,10 +25,10 @@ -{{#if_mono}}
Importing +{{#if_mono}}
@@ -51,5 +51,29 @@
-
{{/if_mono}} + +
+ + +
+
+
+
+