diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 000000000..aa693030a --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,22 @@ +If this is a bug report please make sure you have filled the following in: +(If it's not a bug and a feature request then just remove the below) + +#### Plex Requests.Net Version: + + +#### Operating System: + + +#### Mono Version: + + +#### Applicable Logs (from `/logs/` directory or the Admin page): + +``` +Logs go here (Please make sure you remove any personal information from the logs) +``` + + +#### Reproduction Steps: + +Please include any steps to reproduce the issue, this the request that is causing the problem etc. \ No newline at end of file diff --git a/PlexRequests.Api.Interfaces/IHeadphonesApi.cs b/PlexRequests.Api.Interfaces/IHeadphonesApi.cs new file mode 100644 index 000000000..2dee51d4f --- /dev/null +++ b/PlexRequests.Api.Interfaces/IHeadphonesApi.cs @@ -0,0 +1,35 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: IHeadphonesApi.cs +// Created By: Jamie Rees +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// ************************************************************************/ +#endregion +using System; + +namespace PlexRequests.Api.Interfaces +{ + public interface IHeadphonesApi + { + bool AddAlbum(string apiKey, Uri baseUrl, string albumId); + } +} \ No newline at end of file diff --git a/PlexRequests.Api.Interfaces/IMusicBrainzApi.cs b/PlexRequests.Api.Interfaces/IMusicBrainzApi.cs new file mode 100644 index 000000000..011c430e7 --- /dev/null +++ b/PlexRequests.Api.Interfaces/IMusicBrainzApi.cs @@ -0,0 +1,37 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: IMusicBrainzApi.cs +// Created By: Jamie Rees +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// ************************************************************************/ +#endregion +using PlexRequests.Api.Models.Music; + +namespace PlexRequests.Api.Interfaces +{ + public interface IMusicBrainzApi + { + MusicBrainzSearchResults SearchAlbum(string searchTerm); + MusicBrainzCoverArt GetCoverArt(string releaseId); + MusicBrainzReleaseInfo GetAlbum(string releaseId); + } +} \ No newline at end of file diff --git a/PlexRequests.Api.Interfaces/IPushbulletApi.cs b/PlexRequests.Api.Interfaces/IPushbulletApi.cs index 647280aaf..5df902d4c 100644 --- a/PlexRequests.Api.Interfaces/IPushbulletApi.cs +++ b/PlexRequests.Api.Interfaces/IPushbulletApi.cs @@ -24,6 +24,8 @@ // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // ************************************************************************/ #endregion +using System.Threading.Tasks; + using PlexRequests.Api.Models.Notifications; namespace PlexRequests.Api.Interfaces @@ -38,6 +40,6 @@ namespace PlexRequests.Api.Interfaces /// The message. /// The device identifier. /// - PushbulletResponse Push(string accessToken, string title, string message, string deviceIdentifier = default(string)); + Task PushAsync(string accessToken, string title, string message, string deviceIdentifier = default(string)); } } \ No newline at end of file diff --git a/PlexRequests.Api.Interfaces/IPushoverApi.cs b/PlexRequests.Api.Interfaces/IPushoverApi.cs new file mode 100644 index 000000000..15f93f596 --- /dev/null +++ b/PlexRequests.Api.Interfaces/IPushoverApi.cs @@ -0,0 +1,37 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: IPushoverApi.cs +// Created By: Jamie Rees +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// ************************************************************************/ +#endregion +using System.Threading.Tasks; + +using PlexRequests.Api.Models.Notifications; + +namespace PlexRequests.Api.Interfaces +{ + public interface IPushoverApi + { + Task PushAsync(string accessToken, string message, string userToken); + } +} \ No newline at end of file diff --git a/PlexRequests.Api.Interfaces/ISickRageApi.cs b/PlexRequests.Api.Interfaces/ISickRageApi.cs index a5770b56f..4953b16f6 100644 --- a/PlexRequests.Api.Interfaces/ISickRageApi.cs +++ b/PlexRequests.Api.Interfaces/ISickRageApi.cs @@ -26,17 +26,18 @@ #endregion using System; +using System.Threading.Tasks; using PlexRequests.Api.Models.SickRage; namespace PlexRequests.Api.Interfaces { public interface ISickRageApi { - SickRageTvAdd AddSeries(int tvdbId, int seasoncount, int[] seasons, string quality, string apiKey, + Task AddSeries(int tvdbId, int seasoncount, int[] seasons, string quality, string apiKey, Uri baseUrl); SickRagePing Ping(string apiKey, Uri baseUrl); - SickRageTvAdd AddSeason(int tvdbId, int season, string apiKey, Uri baseUrl); + Task AddSeason(int tvdbId, int season, string apiKey, Uri baseUrl); } } \ No newline at end of file diff --git a/PlexRequests.Api.Interfaces/PlexRequests.Api.Interfaces.csproj b/PlexRequests.Api.Interfaces/PlexRequests.Api.Interfaces.csproj index 3ea7be621..f27825298 100644 --- a/PlexRequests.Api.Interfaces/PlexRequests.Api.Interfaces.csproj +++ b/PlexRequests.Api.Interfaces/PlexRequests.Api.Interfaces.csproj @@ -47,8 +47,11 @@ + + + diff --git a/PlexRequests.Api.Models/Music/HeadphonesAlbumSearchResult.cs b/PlexRequests.Api.Models/Music/HeadphonesAlbumSearchResult.cs new file mode 100644 index 000000000..8aa4684c6 --- /dev/null +++ b/PlexRequests.Api.Models/Music/HeadphonesAlbumSearchResult.cs @@ -0,0 +1,45 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: HeadphonesAlbumSearchResult.cs +// Created By: Jamie Rees +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// ************************************************************************/ +#endregion +namespace PlexRequests.Api.Models.Music +{ + public class HeadphonesAlbumSearchResult + { + public string rgid { get; set; } + public string albumurl { get; set; } + public string tracks { get; set; } + public string date { get; set; } + public string id { get; set; } // Artist ID + public string rgtype { get; set; } + public string title { get; set; } + public string url { get; set; } + public string country { get; set; } + public string albumid { get; set; } // AlbumId + public int score { get; set; } + public string uniquename { get; set; } + public string formats { get; set; } + } +} \ No newline at end of file diff --git a/PlexRequests.Api.Models/Music/HeadphonesArtistSearchResult.cs b/PlexRequests.Api.Models/Music/HeadphonesArtistSearchResult.cs new file mode 100644 index 000000000..15c574277 --- /dev/null +++ b/PlexRequests.Api.Models/Music/HeadphonesArtistSearchResult.cs @@ -0,0 +1,37 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: HeadphonesSearchResult.cs +// Created By: Jamie Rees +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// ************************************************************************/ +#endregion +namespace PlexRequests.Api.Models.Music +{ + public class HeadphonesArtistSearchResult + { + public string url { get; set; } // MusicBrainz url + public int score { get; set; } // Search Match score? + public string name { get; set; } // Artist Name + public string uniquename { get; set; } // Artist Unique Name + public string id { get; set; } // Artist Unique ID for MusicBrainz + } +} \ No newline at end of file diff --git a/PlexRequests.Api.Models/Music/MusicBrainzCoverArt.cs b/PlexRequests.Api.Models/Music/MusicBrainzCoverArt.cs new file mode 100644 index 000000000..40cecba94 --- /dev/null +++ b/PlexRequests.Api.Models/Music/MusicBrainzCoverArt.cs @@ -0,0 +1,55 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: MusicBrainzCoverArt.cs +// Created By: Jamie Rees +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// ************************************************************************/ +#endregion +using System.Collections.Generic; + +namespace PlexRequests.Api.Models.Music +{ + public class Thumbnails + { + public string large { get; set; } + public string small { get; set; } + } + + public class Image + { + public List types { get; set; } + public bool front { get; set; } + public bool back { get; set; } + public int edit { get; set; } + public string image { get; set; } + public string comment { get; set; } + public bool approved { get; set; } + public string id { get; set; } + public Thumbnails thumbnails { get; set; } + } + + public class MusicBrainzCoverArt + { + public List images { get; set; } + public string release { get; set; } + } +} \ No newline at end of file diff --git a/PlexRequests.Api.Models/Music/MusicBrainzReleaseInfo.cs b/PlexRequests.Api.Models/Music/MusicBrainzReleaseInfo.cs new file mode 100644 index 000000000..974703cda --- /dev/null +++ b/PlexRequests.Api.Models/Music/MusicBrainzReleaseInfo.cs @@ -0,0 +1,66 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: MusicBrainzReleaseInfo.cs +// Created By: Jamie Rees +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// ************************************************************************/ +#endregion +using System.Collections.Generic; + +using Newtonsoft.Json; + +namespace PlexRequests.Api.Models.Music +{ + public class CoverArtArchive + { + public int count { get; set; } + public bool back { get; set; } + public bool artwork { get; set; } + public bool front { get; set; } + public bool darkened { get; set; } + } + + + public class MusicBrainzReleaseInfo + { + public string date { get; set; } + public string status { get; set; } + public string asin { get; set; } + public string title { get; set; } + public string quality { get; set; } + public string country { get; set; } + public string packaging { get; set; } + + [JsonProperty(PropertyName = "text-representation")] + public TextRepresentation TextRepresentation { get; set; } + + [JsonProperty(PropertyName = "cover-art-archive")] + public CoverArtArchive CoverArtArchive { get; set; } + public string barcode { get; set; } + public string disambiguation { get; set; } + + [JsonProperty(PropertyName = "release-events")] + public List ReleaseRvents { get; set; } + public string id { get; set; } + } + +} \ No newline at end of file diff --git a/PlexRequests.Api.Models/Music/MusicBrainzSearchResults.cs b/PlexRequests.Api.Models/Music/MusicBrainzSearchResults.cs new file mode 100644 index 000000000..658b8ca4b --- /dev/null +++ b/PlexRequests.Api.Models/Music/MusicBrainzSearchResults.cs @@ -0,0 +1,152 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: MusicBrainzSearchResults.cs +// Created By: Jamie Rees +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// ************************************************************************/ +#endregion +using System.Collections.Generic; + +using Newtonsoft.Json; + +namespace PlexRequests.Api.Models.Music +{ + public class TextRepresentation + { + public string language { get; set; } + public string script { get; set; } + } + + public class Alias + { + [JsonProperty(PropertyName = "sort-name")] + public string SortName { get; set; } + public string name { get; set; } + public object locale { get; set; } + public string type { get; set; } + public object primary { get; set; } + [JsonProperty(PropertyName = "begin-date")] + public object BeginDate { get; set; } + [JsonProperty(PropertyName = "end-date")] + public object EndDate { get; set; } + } + + public class Artist + { + public string id { get; set; } + public string name { get; set; } + [JsonProperty(PropertyName = "sort-date")] + public string SortName { get; set; } + public string disambiguation { get; set; } + public List aliases { get; set; } + } + + public class ArtistCredit + { + public Artist artist { get; set; } + } + + public class ReleaseGroup + { + public string id { get; set; } + [JsonProperty(PropertyName = "primary-type")] + public string PrimaryType { get; set; } + [JsonProperty(PropertyName = "secondary-types")] + public List SecondaryTypes { get; set; } + } + + public class Area + { + public string id { get; set; } + public string name { get; set; } + [JsonProperty(PropertyName = "sort-name")] + public string SortName { get; set; } + [JsonProperty(PropertyName = "iso-3166-1-codes")] + public List ISO31661Codes { get; set; } + } + + public class ReleaseEvent + { + public string date { get; set; } + public Area area { get; set; } + } + + public class Label + { + public string id { get; set; } + public string name { get; set; } + } + + public class LabelInfo + { + [JsonProperty(PropertyName = "catalog-number")] + public string CatalogNumber { get; set; } + public Label label { get; set; } + } + + public class Medium + { + public string format { get; set; } + [JsonProperty(PropertyName = "disc-count")] + public int DiscCount { get; set; } + [JsonProperty(PropertyName = "catalog-number")] + public int CatalogNumber { get; set; } + } + + public class Release + { + public string id { get; set; } + public string score { get; set; } + public int count { get; set; } + public string title { get; set; } + public string status { get; set; } + public string disambiguation { get; set; } + public string packaging { get; set; } + + [JsonProperty(PropertyName = "text-representation")] + public TextRepresentation TextRepresentation { get; set; } + [JsonProperty(PropertyName = "artist-credit")] + public List ArtistCredit { get; set; } + [JsonProperty(PropertyName = "release-group")] + public ReleaseGroup ReleaseGroup { get; set; } + public string date { get; set; } + public string country { get; set; } + [JsonProperty(PropertyName = "release-events")] + public List ReleaseEvents { get; set; } + public string barcode { get; set; } + public string asin { get; set; } + [JsonProperty(PropertyName = "label-info")] + public List LabelInfo { get; set; } + [JsonProperty(PropertyName = "track-count")] + public int TrackCount { get; set; } + public List media { get; set; } + } + + public class MusicBrainzSearchResults + { + public string created { get; set; } + public int count { get; set; } + public int offset { get; set; } + public List releases { get; set; } + } + +} \ No newline at end of file diff --git a/PlexRequests.Api.Models/Notifications/PushoverResponse.cs b/PlexRequests.Api.Models/Notifications/PushoverResponse.cs new file mode 100644 index 000000000..94849fba1 --- /dev/null +++ b/PlexRequests.Api.Models/Notifications/PushoverResponse.cs @@ -0,0 +1,34 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: PushoverResponse.cs +// Created By: Jamie Rees +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// ************************************************************************/ +#endregion +namespace PlexRequests.Api.Models.Notifications +{ + public class PushoverResponse + { + public int status { get; set; } + public string request { get; set; } + } +} \ No newline at end of file diff --git a/PlexRequests.Api.Models/PlexRequests.Api.Models.csproj b/PlexRequests.Api.Models/PlexRequests.Api.Models.csproj index 1078563fe..e24c0e31f 100644 --- a/PlexRequests.Api.Models/PlexRequests.Api.Models.csproj +++ b/PlexRequests.Api.Models/PlexRequests.Api.Models.csproj @@ -48,8 +48,14 @@ + + + + + + @@ -59,9 +65,11 @@ + + diff --git a/PlexRequests.Api.Models/SickRage/SickRageShowInformation.cs b/PlexRequests.Api.Models/SickRage/SickRageShowInformation.cs new file mode 100644 index 000000000..8076008a5 --- /dev/null +++ b/PlexRequests.Api.Models/SickRage/SickRageShowInformation.cs @@ -0,0 +1,85 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: SickRageShowInformation.cs +// Created By: Jamie Rees +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// ************************************************************************/ +#endregion + +using System.Collections.Generic; + +namespace PlexRequests.Api.Models.SickRage +{ + public class Cache + { + public int banner { get; set; } + public int poster { get; set; } + } + + public class QualityDetails + { + public List archive { get; set; } + public List initial { get; set; } + } + + public class SeasonList + { + } + + public class Data + { + public int air_by_date { get; set; } + public string airs { get; set; } + public int anime { get; set; } + public int archive_firstmatch { get; set; } + public Cache cache { get; set; } + public int dvdorder { get; set; } + public int flatten_folders { get; set; } + public List genre { get; set; } + public string imdbid { get; set; } + public int indexerid { get; set; } + public string language { get; set; } + public string location { get; set; } + public string network { get; set; } + public string next_ep_airdate { get; set; } + public int paused { get; set; } + public string quality { get; set; } + public QualityDetails quality_details { get; set; } + public List rls_ignore_words { get; set; } + public List rls_require_words { get; set; } + public int scene { get; set; } + public SeasonList season_list { get; set; } + public string show_name { get; set; } + public int sports { get; set; } + public string status { get; set; } + public int subtitles { get; set; } + public int tvdbid { get; set; } + } + + public class SickRageShowInformation + { + public Data data { get; set; } + public string message { get; set; } + public string result { get; set; } + } + +} \ No newline at end of file diff --git a/PlexRequests.Api.Models/Sonarr/SonarrAddSeries.cs b/PlexRequests.Api.Models/Sonarr/SonarrAddSeries.cs index 534f3068f..23540521f 100644 --- a/PlexRequests.Api.Models/Sonarr/SonarrAddSeries.cs +++ b/PlexRequests.Api.Models/Sonarr/SonarrAddSeries.cs @@ -1,5 +1,7 @@ using System.Collections.Generic; +using Newtonsoft.Json; + namespace PlexRequests.Api.Models.Sonarr { public class Season @@ -23,6 +25,8 @@ namespace PlexRequests.Api.Models.Sonarr public string imdbId { get; set; } public string titleSlug { get; set; } public int id { get; set; } + [JsonIgnore] + public string ErrorMessage { get; set; } } public class AddOptions diff --git a/PlexRequests.Api.Models/Sonarr/SonarrError.cs b/PlexRequests.Api.Models/Sonarr/SonarrError.cs new file mode 100644 index 000000000..ae3fbdfca --- /dev/null +++ b/PlexRequests.Api.Models/Sonarr/SonarrError.cs @@ -0,0 +1,36 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: SonarrError.cs +// Created By: Jamie Rees +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// ************************************************************************/ +#endregion +namespace PlexRequests.Api.Models.Sonarr +{ + public class SonarrError + { + public string propertyName { get; set; } + public string errorMessage { get; set; } + public string attemptedValue { get; set; } + public string[] formattedMessageArguments { get; set; } + } +} \ No newline at end of file diff --git a/PlexRequests.Api/ApiRequest.cs b/PlexRequests.Api/ApiRequest.cs index 62391386b..1b7975462 100644 --- a/PlexRequests.Api/ApiRequest.cs +++ b/PlexRequests.Api/ApiRequest.cs @@ -26,13 +26,9 @@ #endregion using System; using System.IO; -using System.Net; -using System.Text; -using System.Xml; using System.Xml.Serialization; using Newtonsoft.Json; -using Newtonsoft.Json.Linq; using NLog; @@ -96,20 +92,13 @@ namespace PlexRequests.Api throw new ApplicationException(message, response.ErrorException); } - try - { - var json = JsonConvert.DeserializeObject(response.Content); - return json; - } - catch (Exception e) - { - Log.Fatal(e); - Log.Info(response.Content); - throw; - } + + var json = JsonConvert.DeserializeObject(response.Content); + + return json; } - public T DeserializeXml(string input) + private T DeserializeXml(string input) where T : class { var ser = new XmlSerializer(typeof(T)); diff --git a/PlexRequests.Core/RequestService.cs b/PlexRequests.Api/HeadphonesApi.cs similarity index 50% rename from PlexRequests.Core/RequestService.cs rename to PlexRequests.Api/HeadphonesApi.cs index 7788b6436..ed0dac9c6 100644 --- a/PlexRequests.Core/RequestService.cs +++ b/PlexRequests.Api/HeadphonesApi.cs @@ -1,7 +1,7 @@ #region Copyright // /************************************************************************ // Copyright (c) 2016 Jamie Rees -// File: RequestService.cs +// File: HeadphonesApi.cs // Created By: Jamie Rees // // Permission is hereby granted, free of charge, to any person obtaining @@ -24,61 +24,51 @@ // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // ************************************************************************/ #endregion - +using System; using System.Collections.Generic; -using System.Linq; -using PlexRequests.Store; -namespace PlexRequests.Core -{ - public class RequestService : IRequestService - { - public RequestService(IRepository db) - { - Repo = db; - } - - private IRepository Repo { get; set; } +using Newtonsoft.Json; - public long AddRequest(RequestedModel model) - { - return Repo.Insert(model); - } +using NLog; - public bool CheckRequest(int providerId) - { - return Repo.GetAll().Any(x => x.ProviderId == providerId); - } +using PlexRequests.Api.Interfaces; +using PlexRequests.Api.Models.Music; - public void DeleteRequest(RequestedModel model) - { - var entity = Repo.Get(model.Id); - Repo.Delete(entity); - } +using RestSharp; - public bool UpdateRequest(RequestedModel model) +namespace PlexRequests.Api +{ + public class HeadphonesApi : IHeadphonesApi + { + public HeadphonesApi() { - return Repo.Update(model); + Api = new ApiRequest(); } + private ApiRequest Api { get; } + private static readonly Logger Log = LogManager.GetCurrentClassLogger(); - /// - /// Updates all the entities. NOTE: we need to Id to be the original entity - /// - /// The model. - /// - public bool BatchUpdate(List model) + public bool AddAlbum(string apiKey, Uri baseUrl, string albumId) { - return Repo.UpdateAll(model); - } + Log.Trace("Adding album: {0}", albumId); + var request = new RestRequest + { + Resource = "/api?cmd=addAlbum&id={albumId}", + Method = Method.GET + }; - public RequestedModel Get(int id) - { - return Repo.Get(id); - } + request.AddQueryParameter("apikey", apiKey); + request.AddUrlSegment("albumId", albumId); - public IEnumerable GetAll() - { - return Repo.GetAll(); + try + { + //var result = Api.Execute(request, baseUrl); + return false; + } + catch (JsonSerializationException jse) + { + Log.Warn(jse); + return false; // If there is no matching result we do not get returned a JSON string, it just returns "false". + } } } -} +} \ No newline at end of file diff --git a/PlexRequests.Api/MusicBrainzApi.cs b/PlexRequests.Api/MusicBrainzApi.cs new file mode 100644 index 000000000..62b0771be --- /dev/null +++ b/PlexRequests.Api/MusicBrainzApi.cs @@ -0,0 +1,114 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: MusicBrainzApi.cs +// Created By: Jamie Rees +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// ************************************************************************/ +#endregion +using System; + +using Newtonsoft.Json; + +using NLog; + +using PlexRequests.Api.Interfaces; +using PlexRequests.Api.Models.Music; + +using RestSharp; + +namespace PlexRequests.Api +{ + public class MusicBrainzApi : IMusicBrainzApi + { + public MusicBrainzApi() + { + Api = new ApiRequest(); + } + private ApiRequest Api { get; } + private static readonly Logger Log = LogManager.GetCurrentClassLogger(); + private readonly Uri BaseUri = new Uri("http://musicbrainz.org/ws/2/"); + + public MusicBrainzSearchResults SearchAlbum(string searchTerm) + { + Log.Trace("Searching for album: {0}", searchTerm); + var request = new RestRequest + { + Resource = "release/?query={searchTerm}&fmt=json", + Method = Method.GET + }; + request.AddUrlSegment("searchTerm", searchTerm); + + try + { + return Api.ExecuteJson(request, BaseUri); + } + catch (JsonSerializationException jse) + { + Log.Warn(jse); + return new MusicBrainzSearchResults(); // If there is no matching result we do not get returned a JSON string, it just returns "false". + } + } + + public MusicBrainzReleaseInfo GetAlbum(string releaseId) + { + Log.Trace("Getting album: {0}", releaseId); + var request = new RestRequest + { + Resource = "release/{albumId}?fmt=json", + Method = Method.GET + }; + request.AddUrlSegment("albumId", releaseId); + + try + { + return Api.Execute(request, BaseUri); + } + catch (JsonSerializationException jse) + { + Log.Warn(jse); + return new MusicBrainzReleaseInfo(); // If there is no matching result we do not get returned a JSON string, it just returns "false". + } + } + + public MusicBrainzCoverArt GetCoverArt(string releaseId) + { + Log.Trace("Getting cover art for release: {0}", releaseId); + var request = new RestRequest + { + Resource = "release/{releaseId}", + Method = Method.GET + }; + request.AddUrlSegment("releaseId", releaseId); + + try + { + return Api.Execute(request, new Uri("http://coverartarchive.org/")); + } + catch (Exception e) + { + Log.Warn(e); + return new MusicBrainzCoverArt(); // If there is no matching result we do not get returned a JSON string, it just returns "false". + } + } + + } +} \ No newline at end of file diff --git a/PlexRequests.Api/PlexRequests.Api.csproj b/PlexRequests.Api/PlexRequests.Api.csproj index 2f204c975..b655cc2be 100644 --- a/PlexRequests.Api/PlexRequests.Api.csproj +++ b/PlexRequests.Api/PlexRequests.Api.csproj @@ -66,14 +66,17 @@ + True True MockApiData.resx + + diff --git a/PlexRequests.Api/PushbulletApi.cs b/PlexRequests.Api/PushbulletApi.cs index 2c542348d..1e399e048 100644 --- a/PlexRequests.Api/PushbulletApi.cs +++ b/PlexRequests.Api/PushbulletApi.cs @@ -25,6 +25,7 @@ // ************************************************************************/ #endregion using System; +using System.Threading.Tasks; using PlexRequests.Api.Interfaces; using PlexRequests.Api.Models.Notifications; @@ -35,7 +36,7 @@ namespace PlexRequests.Api { public class PushbulletApi : IPushbulletApi { - public PushbulletResponse Push(string accessToken, string title, string message, string deviceIdentifier = default(string)) + public async Task PushAsync(string accessToken, string title, string message, string deviceIdentifier = default(string)) { var request = new RestRequest { @@ -56,7 +57,7 @@ namespace PlexRequests.Api request.AddJsonBody(push); var api = new ApiRequest(); - return api.ExecuteJson(request, new Uri("https://api.pushbullet.com/v2/pushes")); + return await Task.Run(() => api.ExecuteJson(request, new Uri("https://api.pushbullet.com/v2/pushes"))); } } } diff --git a/PlexRequests.Api/PushoverApi.cs b/PlexRequests.Api/PushoverApi.cs new file mode 100644 index 000000000..6d109ca9b --- /dev/null +++ b/PlexRequests.Api/PushoverApi.cs @@ -0,0 +1,57 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: PlexApi.cs +// Created By: Jamie Rees +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// ************************************************************************/ +#endregion +using System; +using System.Threading.Tasks; + +using PlexRequests.Api.Interfaces; +using PlexRequests.Api.Models.Notifications; + +using RestSharp; + +namespace PlexRequests.Api +{ + public class PushoverApi : IPushoverApi + { + public async Task PushAsync(string accessToken, string message, string userToken) + { + var request = new RestRequest + { + Method = Method.POST, + Resource = "messages.json?token={token}&user={user}&message={message}" + }; + + request.AddUrlSegment("token", accessToken); + request.AddUrlSegment("message", message); + request.AddUrlSegment("user", userToken); + + + var api = new ApiRequest(); + return await Task.Run(() => api.ExecuteJson(request, new Uri("https://api.pushover.net/1"))); + } + } +} + diff --git a/PlexRequests.Api/SickrageApi.cs b/PlexRequests.Api/SickrageApi.cs index fe27968fe..fc4714279 100644 --- a/PlexRequests.Api/SickrageApi.cs +++ b/PlexRequests.Api/SickrageApi.cs @@ -28,7 +28,10 @@ #endregion using System; +using System.Diagnostics; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using NLog; using PlexRequests.Api.Interfaces; using PlexRequests.Api.Models.SickRage; @@ -49,7 +52,7 @@ namespace PlexRequests.Api private ApiRequest Api { get; } - public SickRageTvAdd AddSeries(int tvdbId, int seasonCount, int[] seasons, string quality, string apiKey, + public async Task AddSeries(int tvdbId, int seasonCount, int[] seasons, string quality, string apiKey, Uri baseUrl) { var futureStatus = seasons.Length > 0 && !seasons.Any(x => x == seasonCount) ? SickRageStatus.Skipped : SickRageStatus.Wanted; @@ -71,12 +74,33 @@ namespace PlexRequests.Api var obj = Api.Execute(request, baseUrl); - if (seasons.Length > 0 && obj.result != "failure") + + if (obj.result != "failure") + { + var sw = new Stopwatch(); + sw.Start(); + + // Check to see if it's been added yet. + var showInfo = new SickRageShowInformation { message = "Show not found" }; + while (showInfo.message.Equals("Show not found", StringComparison.CurrentCultureIgnoreCase)) + { + showInfo = CheckShowHasBeenAdded(tvdbId, apiKey, baseUrl); + if (sw.ElapsedMilliseconds > 30000) // Break out after 30 seconds, it's not going to get added + { + Log.Warn("Couldn't find out if the show had been added after 10 seconds. I doubt we can change the status to wanted."); + break; + } + } + sw.Stop(); + } + + + if (seasons.Length > 0) { //handle the seasons requested - foreach (int s in seasons) + foreach (var s in seasons) { - var result = AddSeason(tvdbId, s, apiKey, baseUrl); + var result = await AddSeason(tvdbId, s, apiKey, baseUrl); Log.Trace("SickRage adding season results: "); Log.Trace(result.DumpJson()); } @@ -99,7 +123,7 @@ namespace PlexRequests.Api return obj; } - public SickRageTvAdd AddSeason(int tvdbId, int season, string apiKey, Uri baseUrl) + public async Task AddSeason(int tvdbId, int season, string apiKey, Uri baseUrl) { var request = new RestRequest { @@ -111,7 +135,22 @@ namespace PlexRequests.Api request.AddQueryParameter("season", season.ToString()); request.AddQueryParameter("status", SickRageStatus.Wanted); - var obj = Api.Execute(request, baseUrl); + await Task.Run(() => Thread.Sleep(2000)); + return await Task.Run(() => Api.Execute(request, baseUrl)).ConfigureAwait(false); + } + + + public SickRageShowInformation CheckShowHasBeenAdded(int tvdbId, string apiKey, Uri baseUrl) + { + var request = new RestRequest + { + Resource = "/api/{apiKey}/?cmd=show", + Method = Method.GET + }; + request.AddUrlSegment("apiKey", apiKey); + request.AddQueryParameter("tvdbid", tvdbId.ToString()); + + var obj = Api.Execute(request, baseUrl); return obj; } diff --git a/PlexRequests.Api/SonarrApi.cs b/PlexRequests.Api/SonarrApi.cs index 166b41497..f9d68da31 100644 --- a/PlexRequests.Api/SonarrApi.cs +++ b/PlexRequests.Api/SonarrApi.cs @@ -27,9 +27,14 @@ using System; using System.Collections.Generic; using System.Linq; + +using Newtonsoft.Json; + using NLog; using PlexRequests.Api.Interfaces; using PlexRequests.Api.Models.Sonarr; +using PlexRequests.Helpers; + using RestSharp; namespace PlexRequests.Api @@ -56,43 +61,24 @@ namespace PlexRequests.Api public SonarrAddSeries AddSeries(int tvdbId, string title, int qualityId, bool seasonFolders, string rootPath, int seasonCount, int[] seasons, string apiKey, Uri baseUrl) { - + Log.Debug("Adding series {0}", title); + Log.Debug("Seasons = {0}, out of {1} seasons", seasons.DumpJson(), seasonCount); var request = new RestRequest { Resource = "/api/Series?", Method = Method.POST }; - var options = new SonarrAddSeries(); - - - //I'm fairly certain we won't need this logic anymore since we're manually adding the seasons - //if (seasons.Length == 0) - //{ - // options.addOptions = new AddOptions - // { - // ignoreEpisodesWithFiles = true, - // ignoreEpisodesWithoutFiles = true, - // searchForMissingEpisodes = false - // }; - //} - //else - //{ - // options.addOptions = new AddOptions - // { - // ignoreEpisodesWithFiles = false, - // ignoreEpisodesWithoutFiles = false, - // searchForMissingEpisodes = true - // }; - //} - - options.seasonFolder = seasonFolders; - options.title = title; - options.qualityProfileId = qualityId; - options.tvdbId = tvdbId; - options.titleSlug = title; - options.seasons = new List(); - options.rootFolderPath = rootPath; + var options = new SonarrAddSeries + { + seasonFolder = seasonFolders, + title = title, + qualityProfileId = qualityId, + tvdbId = tvdbId, + titleSlug = title, + seasons = new List(), + rootFolderPath = rootPath + }; for (var i = 1; i <= seasonCount; i++) { @@ -104,12 +90,25 @@ namespace PlexRequests.Api options.seasons.Add(season); } + Log.Debug("Sonarr API Options:"); + Log.Debug(options.DumpJson()); + request.AddHeader("X-Api-Key", apiKey); request.AddJsonBody(options); - var obj = Api.ExecuteJson(request, baseUrl); + SonarrAddSeries result; + try + { + result = Api.ExecuteJson(request, baseUrl); + } + catch (JsonSerializationException jse) + { + Log.Error(jse); + var error = Api.ExecuteJson(request, baseUrl); + result = new SonarrAddSeries { ErrorMessage = error.errorMessage }; + } - return obj; + return result; } public SystemStatus SystemStatus(string apiKey, Uri baseUrl) diff --git a/PlexRequests.Core/IRequestService.cs b/PlexRequests.Core/IRequestService.cs index fb68fab37..342e83d05 100644 --- a/PlexRequests.Core/IRequestService.cs +++ b/PlexRequests.Core/IRequestService.cs @@ -33,7 +33,9 @@ namespace PlexRequests.Core public interface IRequestService { long AddRequest(RequestedModel model); - bool CheckRequest(int providerId); + RequestedModel CheckRequest(int providerId); + RequestedModel CheckRequest(string musicId); + void DeleteRequest(RequestedModel request); bool UpdateRequest(RequestedModel model); RequestedModel Get(int id); diff --git a/PlexRequests.Core/JsonRequestService.cs b/PlexRequests.Core/JsonRequestService.cs index 1a99569d3..4808cde3b 100644 --- a/PlexRequests.Core/JsonRequestService.cs +++ b/PlexRequests.Core/JsonRequestService.cs @@ -30,8 +30,10 @@ using System.Text; using Newtonsoft.Json; +using PlexRequests.Helpers; using PlexRequests.Store; using PlexRequests.Store.Models; +using PlexRequests.Store.Repository; namespace PlexRequests.Core { @@ -44,22 +46,30 @@ namespace PlexRequests.Core private IRequestRepository Repo { get; } public long AddRequest(RequestedModel model) { - var entity = new RequestBlobs { Type = model.Type, Content = ReturnBytes(model), ProviderId = model.ProviderId }; + var entity = new RequestBlobs { Type = model.Type, Content = ByteConverterHelper.ReturnBytes(model), ProviderId = model.ProviderId }; var id = Repo.Insert(entity); // TODO Keep an eye on this, since we are now doing 2 DB update for 1 single request, inserting and then updating model.Id = (int)id; - entity = new RequestBlobs { Type = model.Type, Content = ReturnBytes(model), ProviderId = model.ProviderId, Id = (int)id }; + entity = new RequestBlobs { Type = model.Type, Content = ByteConverterHelper.ReturnBytes(model), ProviderId = model.ProviderId, Id = (int)id, MusicId = model.MusicBrainzId}; var result = Repo.Update(entity); return result ? id : -1; } - public bool CheckRequest(int providerId) + public RequestedModel CheckRequest(int providerId) { var blobs = Repo.GetAll(); - return blobs.Any(x => x.ProviderId == providerId); + var blob = blobs.FirstOrDefault(x => x.ProviderId == providerId); + return blob != null ? ByteConverterHelper.ReturnObject(blob.Content) : null; + } + + public RequestedModel CheckRequest(string musicId) + { + var blobs = Repo.GetAll(); + var blob = blobs.FirstOrDefault(x => x.MusicId == musicId); + return blob != null ? ByteConverterHelper.ReturnObject(blob.Content) : null; } public void DeleteRequest(RequestedModel request) @@ -70,15 +80,18 @@ namespace PlexRequests.Core public bool UpdateRequest(RequestedModel model) { - var entity = new RequestBlobs { Type = model.Type, Content = ReturnBytes(model), ProviderId = model.ProviderId, Id = model.Id }; + var entity = new RequestBlobs { Type = model.Type, Content = ByteConverterHelper.ReturnBytes(model), ProviderId = model.ProviderId, Id = model.Id }; return Repo.Update(entity); } public RequestedModel Get(int id) { var blob = Repo.Get(id); - var json = Encoding.UTF8.GetString(blob.Content); - var model = JsonConvert.DeserializeObject(json); + if (blob == null) + { + return new RequestedModel(); + } + var model = ByteConverterHelper.ReturnObject(blob.Content); return model; } @@ -92,16 +105,8 @@ namespace PlexRequests.Core public bool BatchUpdate(List model) { - var entities = model.Select(m => new RequestBlobs { Type = m.Type, Content = ReturnBytes(m), ProviderId = m.ProviderId, Id = m.Id }).ToList(); + var entities = model.Select(m => new RequestBlobs { Type = m.Type, Content = ByteConverterHelper.ReturnBytes(m), ProviderId = m.ProviderId, Id = m.Id }).ToList(); return Repo.UpdateAll(entities); } - - public byte[] ReturnBytes(object obj) - { - var json = JsonConvert.SerializeObject(obj); - var bytes = Encoding.UTF8.GetBytes(json); - - return bytes; - } } } \ No newline at end of file diff --git a/PlexRequests.Core/Models/UserProperties.cs b/PlexRequests.Core/Models/UserProperties.cs new file mode 100644 index 000000000..8cd210d57 --- /dev/null +++ b/PlexRequests.Core/Models/UserProperties.cs @@ -0,0 +1,35 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: UserProperties.cs +// Created By: Jamie Rees +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// ************************************************************************/ +#endregion +namespace PlexRequests.Core.Models +{ + public class UserProperties + { + public string EmailAddress { get; set; } + public bool NotifyOnRelease { get; set; } + public bool NotifyOnApprove { get; set; } + } +} \ No newline at end of file diff --git a/PlexRequests.Core/PlexRequests.Core.csproj b/PlexRequests.Core/PlexRequests.Core.csproj index e1c4ee1f3..735938db4 100644 --- a/PlexRequests.Core/PlexRequests.Core.csproj +++ b/PlexRequests.Core/PlexRequests.Core.csproj @@ -46,6 +46,10 @@ ..\packages\Newtonsoft.Json.8.0.2\lib\net45\Newtonsoft.Json.dll True + + ..\packages\NLog.4.2.3\lib\net45\NLog.dll + True + ..\packages\Octokit.0.19.0\lib\net45\Octokit.dll True @@ -72,7 +76,10 @@ + + + @@ -81,7 +88,6 @@ - diff --git a/PlexRequests.Core/SettingModels/EmailNotificationSettings.cs b/PlexRequests.Core/SettingModels/EmailNotificationSettings.cs index e725b0f0c..6a151e861 100644 --- a/PlexRequests.Core/SettingModels/EmailNotificationSettings.cs +++ b/PlexRequests.Core/SettingModels/EmailNotificationSettings.cs @@ -6,6 +6,7 @@ public int EmailPort { get; set; } public bool Ssl { get; set; } public string RecipientEmail { get; set; } + public string EmailSender { get; set; } public string EmailUsername { get; set; } public string EmailPassword { get; set; } public bool Enabled { get; set; } diff --git a/PlexRequests.Core/SettingModels/HeadphonesSettings.cs b/PlexRequests.Core/SettingModels/HeadphonesSettings.cs new file mode 100644 index 000000000..96deeb821 --- /dev/null +++ b/PlexRequests.Core/SettingModels/HeadphonesSettings.cs @@ -0,0 +1,58 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: CouchPotatoSettings.cs +// Created By: Jamie Rees +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// ************************************************************************/ +#endregion + +using System; +using Newtonsoft.Json; +using PlexRequests.Helpers; + +namespace PlexRequests.Core.SettingModels +{ + public class HeadphonesSettings : Settings + { + public bool Enabled { get; set; } + public string Ip { get; set; } + public int Port { get; set; } + public string ApiKey { get; set; } + public bool Ssl { get; set; } + public string SubDir { get; set; } + + [JsonIgnore] + public Uri FullUri + { + get + { + if (!string.IsNullOrEmpty(SubDir)) + { + var formattedSubDir = Ip.ReturnUriWithSubDir(Port, Ssl, SubDir); + return formattedSubDir; + } + var formatted = Ip.ReturnUri(Port, Ssl); + return formatted; + } + } + } +} \ No newline at end of file diff --git a/PlexRequests.Core/SettingModels/PlexRequestSettings.cs b/PlexRequests.Core/SettingModels/PlexRequestSettings.cs index dc0e96464..4b4009def 100644 --- a/PlexRequests.Core/SettingModels/PlexRequestSettings.cs +++ b/PlexRequests.Core/SettingModels/PlexRequestSettings.cs @@ -24,6 +24,10 @@ // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // ************************************************************************/ #endregion +using Newtonsoft.Json; +using System; +using System.Collections.Generic; + namespace PlexRequests.Core.SettingModels { public class PlexRequestSettings : Settings @@ -32,7 +36,33 @@ namespace PlexRequests.Core.SettingModels public bool SearchForMovies { get; set; } public bool SearchForTvShows { get; set; } - public bool RequireApproval { get; set; } + public bool SearchForMusic { get; set; } + public bool RequireMovieApproval { get; set; } + public bool RequireTvShowApproval { get; set; } + public bool RequireMusicApproval { get; set; } + public bool UsersCanViewOnlyOwnRequests { get; set; } public int WeeklyRequestLimit { get; set; } + public string NoApprovalUsers { get; set; } + + [JsonIgnore] + public List ApprovalWhiteList + { + get + { + var users = new List(); + if (string.IsNullOrEmpty(NoApprovalUsers)) + { + return users; + } + + var splitUsers = NoApprovalUsers.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var user in splitUsers) + { + if (!string.IsNullOrWhiteSpace(user)) + users.Add(user.Trim()); + } + return users; + } + } } } diff --git a/PlexRequests.Core/SettingModels/PushoverNotificationSettings.cs b/PlexRequests.Core/SettingModels/PushoverNotificationSettings.cs new file mode 100644 index 000000000..ac6c4c435 --- /dev/null +++ b/PlexRequests.Core/SettingModels/PushoverNotificationSettings.cs @@ -0,0 +1,9 @@ +namespace PlexRequests.Core.SettingModels +{ + public class PushoverNotificationSettings : Settings + { + public bool Enabled { get; set; } + public string AccessToken { get; set; } + public string UserToken { get; set; } + } +} \ No newline at end of file diff --git a/PlexRequests.Core/SettingsServiceV2.cs b/PlexRequests.Core/SettingsServiceV2.cs index 067b3b44b..3fb53cd35 100644 --- a/PlexRequests.Core/SettingsServiceV2.cs +++ b/PlexRequests.Core/SettingsServiceV2.cs @@ -30,6 +30,7 @@ using PlexRequests.Core.SettingModels; using PlexRequests.Helpers; using PlexRequests.Store; using PlexRequests.Store.Models; +using PlexRequests.Store.Repository; namespace PlexRequests.Core { diff --git a/PlexRequests.Core/Setup.cs b/PlexRequests.Core/Setup.cs index 63afee327..69d3f9000 100644 --- a/PlexRequests.Core/Setup.cs +++ b/PlexRequests.Core/Setup.cs @@ -30,6 +30,7 @@ using System.Collections.Generic; using System.Linq; using Mono.Data.Sqlite; +using NLog; using PlexRequests.Api; using PlexRequests.Core.SettingModels; using PlexRequests.Helpers; @@ -40,6 +41,9 @@ namespace PlexRequests.Core { public class Setup { + public const int SchemaVersion = 1; + + private static Logger Log = LogManager.GetCurrentClassLogger(); private static DbConfiguration Db { get; set; } public string SetupDb() { @@ -53,16 +57,46 @@ namespace PlexRequests.Core } MigrateDb(); + CheckSchema(); return Db.DbConnection().ConnectionString; } public static string ConnectionString => Db.DbConnection().ConnectionString; + + private void CheckSchema() + { + var connection = Db.DbConnection(); + var schema = connection.GetSchemaVersion(); + if (schema == null) + { + connection.CreateSchema(); // Set the default. + schema = connection.GetSchemaVersion(); + } + + var version = schema.SchemaVersion; + if (version == 0) + { + connection.UpdateSchemaVersion(SchemaVersion); + try + { + TableCreation.AlterTable(Db.DbConnection(), "RequestBlobs", "ADD COLUMN", "MusicId", false, "TEXT"); + } + catch (Exception e) + { + Log.Error("Tried updating the schema to version 1"); + Log.Error(e); + } + return; + } + } + private void CreateDefaultSettingsPage() { var defaultSettings = new PlexRequestSettings { - RequireApproval = true, + RequireTvShowApproval = true, + RequireMovieApproval = true, SearchForMovies = true, SearchForTvShows = true, WeeklyRequestLimit = 0 @@ -71,11 +105,12 @@ namespace PlexRequests.Core s.SaveSettings(defaultSettings); } - private void MigrateDb() // TODO: Remove when no longer needed + private void MigrateDb() // TODO: Remove in v1.7 { + var result = new List(); RequestedModel[] requestedModels; - var repo = new GenericRepository(Db); + var repo = new GenericRepository(Db, new MemoryCacheProvider()); try { var records = repo.GetAll(); @@ -120,7 +155,7 @@ namespace PlexRequests.Core result.Add(id); } - foreach (var source in requestedModels.Where(x => x.Type== RequestType.Movie)) + foreach (var source in requestedModels.Where(x => x.Type == RequestType.Movie)) { var id = jsonRepo.AddRequest(source); result.Add(id); diff --git a/PlexRequests.Core/UserMapper.cs b/PlexRequests.Core/UserMapper.cs index c073661b4..a846652c5 100644 --- a/PlexRequests.Core/UserMapper.cs +++ b/PlexRequests.Core/UserMapper.cs @@ -25,6 +25,7 @@ // ************************************************************************/ #endregion using System; +using System.Collections.Generic; using System.Linq; using System.Security; @@ -32,6 +33,7 @@ using Nancy; using Nancy.Authentication.Forms; using Nancy.Security; +using PlexRequests.Core.Models; using PlexRequests.Helpers; using PlexRequests.Store; @@ -46,7 +48,7 @@ namespace PlexRequests.Core private static ISqliteConfiguration Db { get; set; } public IUserIdentity GetUserFromIdentifier(Guid identifier, NancyContext context) { - var repo = new UserRepository(Db); + var repo = new UserRepository(Db); var user = repo.Get(identifier.ToString()); @@ -58,6 +60,7 @@ namespace PlexRequests.Core return new UserIdentity { UserName = user.UserName, + Claims = ByteConverterHelper.ReturnObject(user.Claims) }; } @@ -84,24 +87,32 @@ namespace PlexRequests.Core { var repo = new UserRepository(Db); var users = repo.GetAll(); - + return users.Any(); } - public static Guid? CreateUser(string username, string password) + public static Guid? CreateUser(string username, string password, string[] claims = default(string[])) { var repo = new UserRepository(Db); var salt = PasswordHasher.GenerateSalt(); - var userModel = new UsersModel { UserName = username, UserGuid = Guid.NewGuid().ToString(), Salt = salt, Hash = PasswordHasher.ComputeHash(password, salt)}; + var userModel = new UsersModel + { + UserName = username, + UserGuid = Guid.NewGuid().ToString(), + Salt = salt, + Hash = PasswordHasher.ComputeHash(password, salt), + Claims = ByteConverterHelper.ReturnBytes(claims), + UserProperties = ByteConverterHelper.ReturnBytes(new UserProperties()) + }; repo.Insert(userModel); var userRecord = repo.Get(userModel.UserGuid); - + return new Guid(userRecord.UserGuid); } - public static bool UpdateUser(string username, string oldPassword, string newPassword) + public static bool UpdatePassword(string username, string oldPassword, string newPassword) { var repo = new UserRepository(Db); var users = repo.GetAll(); @@ -123,5 +134,11 @@ namespace PlexRequests.Core return repo.Update(userToChange); } + + public static IEnumerable GetUsers() + { + var repo = new UserRepository(Db); + return repo.GetAll(); + } } } diff --git a/PlexRequests.Core/packages.config b/PlexRequests.Core/packages.config index ddcb2361b..6fae42bd4 100644 --- a/PlexRequests.Core/packages.config +++ b/PlexRequests.Core/packages.config @@ -3,6 +3,7 @@ + \ No newline at end of file diff --git a/PlexRequests.Helpers/ByteConverterHelper.cs b/PlexRequests.Helpers/ByteConverterHelper.cs new file mode 100644 index 000000000..87d569592 --- /dev/null +++ b/PlexRequests.Helpers/ByteConverterHelper.cs @@ -0,0 +1,54 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: ByteConverterHelper.cs +// Created By: Jamie Rees +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// ************************************************************************/ +#endregion +using System.Text; + +using Newtonsoft.Json; + +namespace PlexRequests.Helpers +{ + public class ByteConverterHelper + { + public static byte[] ReturnBytes(object obj) + { + var json = JsonConvert.SerializeObject(obj); + var bytes = Encoding.UTF8.GetBytes(json); + + return bytes; + } + + public static T ReturnObject(byte[] bytes) + { + var json = Encoding.UTF8.GetString(bytes); + var model = JsonConvert.DeserializeObject(json); + return model; + } + public static string ReturnFromBytes(byte[] bytes) + { + return Encoding.UTF8.GetString(bytes); + } + } +} \ No newline at end of file diff --git a/PlexRequests.Helpers/DateTimeHelper.cs b/PlexRequests.Helpers/DateTimeHelper.cs new file mode 100644 index 000000000..88103dcdf --- /dev/null +++ b/PlexRequests.Helpers/DateTimeHelper.cs @@ -0,0 +1,26 @@ +using System; +using System.Linq; + +namespace PlexRequests.Helpers +{ + public static class DateTimeHelper + { + public static DateTimeOffset OffsetUTCDateTime(DateTime utcDateTime, int minuteOffset) + { + //TimeSpan ts = TimeSpan.FromMinutes(-minuteOffset); + //return new DateTimeOffset(utcDateTime).ToOffset(ts); + + // this is a workaround below to work with MONO + var tzi = FindTimeZoneFromOffset(minuteOffset); + var utcOffset = tzi.GetUtcOffset(utcDateTime); + var newDate = utcDateTime + utcOffset; + return new DateTimeOffset(newDate.Ticks, utcOffset); + } + + private static TimeZoneInfo FindTimeZoneFromOffset(int minuteOffset) + { + var tzc = TimeZoneInfo.GetSystemTimeZones(); + return tzc.FirstOrDefault(x => x.BaseUtcOffset.TotalMinutes == -minuteOffset); + } + } +} diff --git a/PlexRequests.Helpers/LoggingHelper.cs b/PlexRequests.Helpers/LoggingHelper.cs index b3ee3dc1b..49eb9d0f7 100644 --- a/PlexRequests.Helpers/LoggingHelper.cs +++ b/PlexRequests.Helpers/LoggingHelper.cs @@ -25,9 +25,14 @@ // ************************************************************************/ #endregion using System; +using System.Data; using Newtonsoft.Json; +using NLog; +using NLog.Config; +using NLog.Targets; + namespace PlexRequests.Helpers { public static class LoggingHelper @@ -55,5 +60,119 @@ namespace PlexRequests.Helpers } return dumpTarget.ToString(); } + + public static void ConfigureLogging(string connectionString) + { + LogManager.ThrowExceptions = true; + // Step 1. Create configuration object + var config = new LoggingConfiguration(); + + // Step 2. Create targets and add them to the configuration + var databaseTarget = new DatabaseTarget + { + CommandType = CommandType.Text, + ConnectionString = connectionString, + DBProvider = "Mono.Data.Sqlite.SqliteConnection, Mono.Data.Sqlite, Version=4.0.0.0, Culture=neutral, PublicKeyToken=0738eb9f132ed756", + Name = "database" + }; + + var messageParam = new DatabaseParameterInfo { Name = "@Message", Layout = "${message}" }; + var callsiteParam = new DatabaseParameterInfo { Name = "@Callsite", Layout = "${callsite}" }; + var levelParam = new DatabaseParameterInfo { Name = "@Level", Layout = "${level}" }; + var dateParam = new DatabaseParameterInfo { Name = "@Date", Layout = "${date}" }; + var loggerParam = new DatabaseParameterInfo { Name = "@Logger", Layout = "${logger}" }; + var exceptionParam = new DatabaseParameterInfo { Name = "@Exception", Layout = "${exception:tostring}" }; + + databaseTarget.Parameters.Add(messageParam); + databaseTarget.Parameters.Add(callsiteParam); + databaseTarget.Parameters.Add(levelParam); + databaseTarget.Parameters.Add(dateParam); + databaseTarget.Parameters.Add(loggerParam); + databaseTarget.Parameters.Add(exceptionParam); + + databaseTarget.CommandText = "INSERT INTO Logs (Date,Level,Logger, Message, Callsite, Exception) VALUES(@Date,@Level,@Logger, @Message, @Callsite, @Exception);"; + config.AddTarget("database", databaseTarget); + + // Step 4. Define rules + var rule1 = new LoggingRule("*", LogLevel.Info, databaseTarget); + config.LoggingRules.Add(rule1); + + + var fileTarget = new FileTarget + { + Name = "file", + FileName = "logs/${shortdate}.log", + Layout = "${date} ${logger} ${level}: ${message} ${exception:tostring}", + CreateDirs = true + }; + config.AddTarget(fileTarget); + var rule2 = new LoggingRule("*", LogLevel.Trace, fileTarget); + config.LoggingRules.Add(rule2); + + // Step 5. Activate the configuration + LogManager.Configuration = config; + } + + public static void ReconfigureLogLevel(LogLevel level) + { + + foreach (var rule in LogManager.Configuration.LoggingRules) + { + // Remove all levels + rule.DisableLoggingForLevel(LogLevel.Trace); + rule.DisableLoggingForLevel(LogLevel.Info); + rule.DisableLoggingForLevel(LogLevel.Debug); + rule.DisableLoggingForLevel(LogLevel.Warn); + rule.DisableLoggingForLevel(LogLevel.Error); + rule.DisableLoggingForLevel(LogLevel.Fatal); + + + if (level == LogLevel.Trace) + { + rule.EnableLoggingForLevel(LogLevel.Trace); + rule.EnableLoggingForLevel(LogLevel.Info); + rule.EnableLoggingForLevel(LogLevel.Debug); + rule.EnableLoggingForLevel(LogLevel.Warn); + rule.EnableLoggingForLevel(LogLevel.Error); + rule.EnableLoggingForLevel(LogLevel.Fatal); + } + if (level == LogLevel.Info) + { + rule.EnableLoggingForLevel(LogLevel.Info); + rule.EnableLoggingForLevel(LogLevel.Debug); + rule.EnableLoggingForLevel(LogLevel.Warn); + rule.EnableLoggingForLevel(LogLevel.Error); + rule.EnableLoggingForLevel(LogLevel.Fatal); + } + if (level == LogLevel.Debug) + { + rule.EnableLoggingForLevel(LogLevel.Debug); + rule.EnableLoggingForLevel(LogLevel.Warn); + rule.EnableLoggingForLevel(LogLevel.Error); + rule.EnableLoggingForLevel(LogLevel.Fatal); + } + if (level == LogLevel.Warn) + { + rule.EnableLoggingForLevel(LogLevel.Warn); + rule.EnableLoggingForLevel(LogLevel.Error); + rule.EnableLoggingForLevel(LogLevel.Fatal); + } + if (level == LogLevel.Error) + { + rule.EnableLoggingForLevel(LogLevel.Error); + rule.EnableLoggingForLevel(LogLevel.Fatal); + } + if (level == LogLevel.Fatal) + { + rule.EnableLoggingForLevel(LogLevel.Fatal); + } + } + + + //Call to update existing Loggers created with GetLogger() or + //GetCurrentClassLogger() + LogManager.ReconfigExistingLoggers(); + } } } + diff --git a/PlexRequests.Helpers/PlexRequests.Helpers.csproj b/PlexRequests.Helpers/PlexRequests.Helpers.csproj index beaf7c117..b2bf29a0a 100644 --- a/PlexRequests.Helpers/PlexRequests.Helpers.csproj +++ b/PlexRequests.Helpers/PlexRequests.Helpers.csproj @@ -35,6 +35,10 @@ ..\packages\Newtonsoft.Json.8.0.2\lib\net45\Newtonsoft.Json.dll True + + ..\packages\NLog.4.2.3\lib\net45\NLog.dll + True + @@ -47,6 +51,8 @@ + + diff --git a/PlexRequests.Helpers/packages.config b/PlexRequests.Helpers/packages.config index 47cebb403..dc63c2a11 100644 --- a/PlexRequests.Helpers/packages.config +++ b/PlexRequests.Helpers/packages.config @@ -1,4 +1,5 @@  + \ No newline at end of file diff --git a/PlexRequests.Services.Tests/NotificationServiceTests.cs b/PlexRequests.Services.Tests/NotificationServiceTests.cs index ffbd75bfe..940bcedda 100644 --- a/PlexRequests.Services.Tests/NotificationServiceTests.cs +++ b/PlexRequests.Services.Tests/NotificationServiceTests.cs @@ -1,33 +1,37 @@ #region Copyright -// /************************************************************************ -// Copyright (c) 2016 Jamie Rees -// File: NotificationServiceTests.cs -// Created By: Jamie Rees -// -// Permission is hereby granted, free of charge, to any person obtaining -// a copy of this software and associated documentation files (the -// "Software"), to deal in the Software without restriction, including -// without limitation the rights to use, copy, modify, merge, publish, -// distribute, sublicense, and/or sell copies of the Software, and to -// permit persons to whom the Software is furnished to do so, subject to -// the following conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -// ************************************************************************/ +/************************************************************************ + Copyright (c) 2016 Jamie Rees + File: NotificationServiceTests.cs + Created By: Jamie Rees + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ************************************************************************/ #endregion +using System; +using System.Threading.Tasks; + using Moq; using NUnit.Framework; +using PlexRequests.Services.Interfaces; using PlexRequests.Services.Notification; namespace PlexRequests.Services.Tests @@ -35,9 +39,15 @@ namespace PlexRequests.Services.Tests [TestFixture] public class NotificationServiceTests { + public NotificationService NotificationService { get; set; } + + [SetUp] + public void Setup() + { + NotificationService = new NotificationService(); + } [Test] - [Ignore("Need to rework due to static class")] public void SubscribeNewNotifier() { var notificationMock = new Mock(); @@ -49,7 +59,6 @@ namespace PlexRequests.Services.Tests } [Test] - [Ignore("Need to rework due to static class")] public void SubscribeExistingNotifier() { var notificationMock1 = new Mock(); @@ -68,7 +77,6 @@ namespace PlexRequests.Services.Tests } [Test] - [Ignore("Need to rework due to static class")] public void UnSubscribeMissingNotifier() { var notificationMock = new Mock(); @@ -79,7 +87,6 @@ namespace PlexRequests.Services.Tests } [Test] - [Ignore("Need to rework due to static class")] public void UnSubscribeNotifier() { var notificationMock = new Mock(); @@ -92,17 +99,15 @@ namespace PlexRequests.Services.Tests } [Test] - [Ignore("Need to rework due to static class")] public void PublishWithNoObservers() { - Assert.DoesNotThrow( - () => - { NotificationService.Publish(string.Empty, string.Empty); }); + Assert.DoesNotThrowAsync( + async() => + { await NotificationService.Publish(new NotificationModel()); }); } [Test] - [Ignore("Need to rework due to static class")] - public void PublishAllNotifiers() + public async Task PublishAllNotifiers() { var notificationMock1 = new Mock(); var notificationMock2 = new Mock(); @@ -112,11 +117,21 @@ namespace PlexRequests.Services.Tests NotificationService.Subscribe(notificationMock2.Object); Assert.That(NotificationService.Observers.Count, Is.EqualTo(2)); + var model = new NotificationModel { Title = "abc", Body = "test" }; + await NotificationService.Publish(model); - NotificationService.Publish("a","b"); + notificationMock1.Verify(x => x.NotifyAsync(model), Times.Once); + notificationMock2.Verify(x => x.NotifyAsync(model), Times.Once); + } - notificationMock1.Verify(x => x.Notify("a","b"), Times.Once); - notificationMock2.Verify(x => x.Notify("a","b"), Times.Once); + [Test] + public async Task PublishWithException() + { + var notificationMock = new Mock(); + notificationMock.Setup(x => x.NotifyAsync(It.IsAny())).Throws(); + notificationMock.SetupGet(x => x.NotificationName).Returns("Notification1"); + NotificationService.Subscribe(notificationMock.Object); + await NotificationService.Publish(new NotificationModel()); } } } \ No newline at end of file diff --git a/PlexRequests.Services.Tests/PlexAvailabilityCheckerTests.cs b/PlexRequests.Services.Tests/PlexAvailabilityCheckerTests.cs index 8e641181e..2fd0f4803 100644 --- a/PlexRequests.Services.Tests/PlexAvailabilityCheckerTests.cs +++ b/PlexRequests.Services.Tests/PlexAvailabilityCheckerTests.cs @@ -32,12 +32,12 @@ using Moq; using NUnit.Framework; using PlexRequests.Api.Interfaces; -using PlexRequests.Api.Models; using PlexRequests.Api.Models.Plex; using PlexRequests.Core; using PlexRequests.Core.SettingModels; using PlexRequests.Helpers.Exceptions; using PlexRequests.Services.Interfaces; +using PlexRequests.Store; namespace PlexRequests.Services.Tests { @@ -66,7 +66,7 @@ namespace PlexRequests.Services.Tests var requestMock = new Mock(); var plexMock = new Mock(); - var searchResult = new PlexSearch {Video = new List diff --git a/PlexRequests.Services/AvailabilityUpdateService.cs b/PlexRequests.Services/AvailabilityUpdateService.cs index 0774307e6..19b23bbe0 100644 --- a/PlexRequests.Services/AvailabilityUpdateService.cs +++ b/PlexRequests.Services/AvailabilityUpdateService.cs @@ -48,9 +48,12 @@ namespace PlexRequests.Services { public AvailabilityUpdateService() { + var memCache = new MemoryCacheProvider(); + var dbConfig = new DbConfiguration(new SqliteFactory()); + var repo = new SettingsJsonRepository(dbConfig, memCache); + ConfigurationReader = new ConfigurationReader(); - var repo = new SettingsJsonRepository(new DbConfiguration(new SqliteFactory()), new MemoryCacheProvider()); - Checker = new PlexAvailabilityChecker(new SettingsServiceV2(repo), new SettingsServiceV2(repo), new JsonRequestService(new RequestJsonRepository(new DbConfiguration(new SqliteFactory()), new MemoryCacheProvider())), new PlexApi()); + Checker = new PlexAvailabilityChecker(new SettingsServiceV2(repo), new SettingsServiceV2(repo), new JsonRequestService(new RequestJsonRepository(dbConfig, memCache)), new PlexApi()); HostingEnvironment.RegisterObject(this); } diff --git a/PlexRequests.Services/Notification/INotification.cs b/PlexRequests.Services/Interfaces/INotification.cs similarity index 73% rename from PlexRequests.Services/Notification/INotification.cs rename to PlexRequests.Services/Interfaces/INotification.cs index 6b85bd178..2e4e55ea4 100644 --- a/PlexRequests.Services/Notification/INotification.cs +++ b/PlexRequests.Services/Interfaces/INotification.cs @@ -24,23 +24,19 @@ // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // ************************************************************************/ #endregion -namespace PlexRequests.Services.Notification +using System.Threading.Tasks; + +using PlexRequests.Services.Notification; +using PlexRequests.Core.SettingModels; + +namespace PlexRequests.Services.Interfaces { public interface INotification { - /// - /// Gets the name of the notification. - /// - /// - /// The name of the notification. - /// string NotificationName { get; } - /// - /// Notifies the specified title. - /// - /// The title. - /// The requester. - /// - bool Notify(string title, string requester); + + Task NotifyAsync(NotificationModel model); + + Task NotifyAsync(NotificationModel model, Settings settings); } } \ No newline at end of file diff --git a/PlexRequests.Services/Interfaces/INotificationService.cs b/PlexRequests.Services/Interfaces/INotificationService.cs new file mode 100644 index 000000000..91563c6de --- /dev/null +++ b/PlexRequests.Services/Interfaces/INotificationService.cs @@ -0,0 +1,42 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: INotificationService.cs +// Created By: Jamie Rees +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// ************************************************************************/ +#endregion +using System.Threading.Tasks; + +using PlexRequests.Services.Notification; +using PlexRequests.Core.SettingModels; + +namespace PlexRequests.Services.Interfaces +{ + public interface INotificationService + { + Task Publish(NotificationModel model); + Task Publish(NotificationModel model, Settings settings); + void Subscribe(INotification notification); + void UnSubscribe(INotification notification); + + } +} \ No newline at end of file diff --git a/PlexRequests.Services/Notification/EmailMessageNotification.cs b/PlexRequests.Services/Notification/EmailMessageNotification.cs index 2c5c84158..4a359fb23 100644 --- a/PlexRequests.Services/Notification/EmailMessageNotification.cs +++ b/PlexRequests.Services/Notification/EmailMessageNotification.cs @@ -27,11 +27,13 @@ using System; using System.Net; using System.Net.Mail; +using System.Threading.Tasks; using NLog; using PlexRequests.Core; using PlexRequests.Core.SettingModels; +using PlexRequests.Services.Interfaces; namespace PlexRequests.Services.Notification { @@ -42,67 +44,159 @@ namespace PlexRequests.Services.Notification EmailNotificationSettings = settings; } - private static Logger Log = LogManager.GetCurrentClassLogger(); + private static readonly Logger Log = LogManager.GetCurrentClassLogger(); private ISettingsService EmailNotificationSettings { get; } public string NotificationName => "EmailMessageNotification"; - public bool Notify(string title, string requester) + + public async Task NotifyAsync(NotificationModel model) { var configuration = GetConfiguration(); - if (!ValidateConfiguration(configuration)) + await NotifyAsync(model, configuration); + } + + public async Task NotifyAsync(NotificationModel model, Settings settings) + { + if (settings == null) await NotifyAsync(model); + + var emailSettings = (EmailNotificationSettings)settings; + + if (!ValidateConfiguration(emailSettings)) return; + + switch (model.NotificationType) + { + case NotificationType.NewRequest: + await EmailNewRequest(model, emailSettings); + break; + case NotificationType.Issue: + await EmailIssue(model, emailSettings); + break; + case NotificationType.RequestAvailable: + throw new NotImplementedException(); + + case NotificationType.RequestApproved: + throw new NotImplementedException(); + + case NotificationType.AdminNote: + throw new NotImplementedException(); + + case NotificationType.Test: + await EmailTest(model, emailSettings); + break; + + default: + throw new ArgumentOutOfRangeException(); + } + + } + + private EmailNotificationSettings GetConfiguration() + { + var settings = EmailNotificationSettings.GetSettings(); + return settings; + } + + private bool ValidateConfiguration(EmailNotificationSettings settings) + { + if (!settings.Enabled) { return false; } + if (string.IsNullOrEmpty(settings.EmailHost) || string.IsNullOrEmpty(settings.EmailUsername) || string.IsNullOrEmpty(settings.EmailPassword) || string.IsNullOrEmpty(settings.RecipientEmail) || string.IsNullOrEmpty(settings.EmailPort.ToString())) + { + return false; + } + + return true; + } + private async Task EmailNewRequest(NotificationModel model, EmailNotificationSettings settings) + { var message = new MailMessage { IsBodyHtml = true, - To = { new MailAddress(configuration.RecipientEmail) }, - Body = $"User {requester} has requested {title}!", - From = new MailAddress(configuration.EmailUsername), - Subject = $"New Request for {title}!" + To = { new MailAddress(settings.RecipientEmail) }, + Body = $"Hello! The user '{model.User}' has requested {model.Title}! Please log in to approve this request. Request Date: {model.DateTime.ToString("f")}", + From = new MailAddress(settings.EmailSender), + Subject = $"Plex Requests: New request for {model.Title}!" }; try { - using (var smtp = new SmtpClient(configuration.EmailHost, configuration.EmailPort)) + using (var smtp = new SmtpClient(settings.EmailHost, settings.EmailPort)) { - smtp.Credentials = new NetworkCredential(configuration.EmailUsername, configuration.EmailPassword); - smtp.EnableSsl = configuration.Ssl; - smtp.Send(message); - return true; + smtp.Credentials = new NetworkCredential(settings.EmailUsername, settings.EmailPassword); + smtp.EnableSsl = settings.Ssl; + await smtp.SendMailAsync(message).ConfigureAwait(false); } } catch (SmtpException smtp) { - Log.Fatal(smtp); + Log.Error(smtp); } catch (Exception e) { - Log.Fatal(e); + Log.Error(e); } - return false; } - private EmailNotificationSettings GetConfiguration() + private async Task EmailIssue(NotificationModel model, EmailNotificationSettings settings) { - var settings = EmailNotificationSettings.GetSettings(); - return settings; + var message = new MailMessage + { + IsBodyHtml = true, + To = { new MailAddress(settings.RecipientEmail) }, + Body = $"Hello! The user '{model.User}' has reported a new issue {model.Body} for the title {model.Title}!", + From = new MailAddress(settings.RecipientEmail), + Subject = $"Plex Requests: New issue for {model.Title}!" + }; + + try + { + using (var smtp = new SmtpClient(settings.EmailHost, settings.EmailPort)) + { + smtp.Credentials = new NetworkCredential(settings.EmailUsername, settings.EmailPassword); + smtp.EnableSsl = settings.Ssl; + await smtp.SendMailAsync(message).ConfigureAwait(false); + } + } + catch (SmtpException smtp) + { + Log.Error(smtp); + } + catch (Exception e) + { + Log.Error(e); + } } - private bool ValidateConfiguration(EmailNotificationSettings settings) + private async Task EmailTest(NotificationModel model, EmailNotificationSettings settings) { - if (!settings.Enabled) + var message = new MailMessage { - return false; + IsBodyHtml = true, + To = { new MailAddress(settings.RecipientEmail) }, + Body = "This is just a test! Success!", + From = new MailAddress(settings.RecipientEmail), + Subject = "Plex Requests: Test Message!" + }; + + try + { + using (var smtp = new SmtpClient(settings.EmailHost, settings.EmailPort)) + { + smtp.Credentials = new NetworkCredential(settings.EmailUsername, settings.EmailPassword); + smtp.EnableSsl = settings.Ssl; + await smtp.SendMailAsync(message).ConfigureAwait(false); + } } - if (string.IsNullOrEmpty(settings.EmailHost) || string.IsNullOrEmpty(settings.EmailUsername) - || string.IsNullOrEmpty(settings.EmailPassword) || string.IsNullOrEmpty(settings.RecipientEmail) - || string.IsNullOrEmpty(settings.EmailPort.ToString())) + catch (SmtpException smtp) { - return false; + Log.Error(smtp); + } + catch (Exception e) + { + Log.Error(e); } - - return true; } } } \ No newline at end of file diff --git a/PlexRequests.Services/Notification/NotificationModel.cs b/PlexRequests.Services/Notification/NotificationModel.cs new file mode 100644 index 000000000..264d3e609 --- /dev/null +++ b/PlexRequests.Services/Notification/NotificationModel.cs @@ -0,0 +1,39 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: NotificationModel.cs +// Created By: Jamie Rees +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// ************************************************************************/ +#endregion +using System; + +namespace PlexRequests.Services.Notification +{ + public class NotificationModel + { + public string Title { get; set; } + public string Body { get; set; } + public DateTime DateTime { get; set; } + public NotificationType NotificationType { get; set; } + public string User { get; set; } + } +} \ No newline at end of file diff --git a/PlexRequests.Services/Notification/NotificationService.cs b/PlexRequests.Services/Notification/NotificationService.cs index 81968c28d..35e52fd7d 100644 --- a/PlexRequests.Services/Notification/NotificationService.cs +++ b/PlexRequests.Services/Notification/NotificationService.cs @@ -24,64 +24,70 @@ // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // ************************************************************************/ #endregion -using System.Collections.Generic; -using System.Threading; +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Threading.Tasks; using NLog; -using PlexRequests.Helpers; +using PlexRequests.Services.Interfaces; +using PlexRequests.Core.SettingModels; namespace PlexRequests.Services.Notification { - public static class NotificationService + public class NotificationService : INotificationService { - private static Logger Log = LogManager.GetCurrentClassLogger(); - public static Dictionary Observers { get; } + public ConcurrentDictionary Observers { get; } = new ConcurrentDictionary(); - static NotificationService() + public async Task Publish(NotificationModel model) { - Observers = new Dictionary(); + var notificationTasks = Observers.Values.Select(notification => NotifyAsync(notification, model)); + + await Task.WhenAll(notificationTasks).ConfigureAwait(false); } - public static void Publish(string title, string requester) + public async Task Publish(NotificationModel model, Settings settings) { - Log.Trace("Notifying all observers: "); - Log.Trace(Observers.DumpJson()); - foreach (var observer in Observers) - { - var notification = observer.Value; + var notificationTasks = Observers.Values.Select(notification => NotifyAsync(notification, model, settings)); - new Thread(() => - { - Thread.CurrentThread.IsBackground = true; - notification.Notify(title, requester); - }).Start(); - } + await Task.WhenAll(notificationTasks).ConfigureAwait(false); + } + + public void Subscribe(INotification notification) + { + Observers.TryAdd(notification.NotificationName, notification); } - public static void Subscribe(INotification notification) + public void UnSubscribe(INotification notification) { - INotification notificationValue; - if (Observers.TryGetValue(notification.NotificationName, out notificationValue)) + Observers.TryRemove(notification.NotificationName, out notification); + } + + private static async Task NotifyAsync(INotification notification, NotificationModel model) + { + try { - return; + await notification.NotifyAsync(model).ConfigureAwait(false); + } + catch (Exception ex) + { + Log.Error(ex, $"Notification '{notification.NotificationName}' failed with exception"); } - Observers[notification.NotificationName] = notification; } - public static void UnSubscribe(INotification notification) + private static async Task NotifyAsync(INotification notification, NotificationModel model, Settings settings) { - Log.Trace("Unsubscribing Observer {0}", notification.NotificationName); - INotification notificationValue; - if (!Observers.TryGetValue(notification.NotificationName, out notificationValue)) + try + { + await notification.NotifyAsync(model, settings).ConfigureAwait(false); + } + catch (Exception ex) { - Log.Trace("Observer {0} doesn't exist to Unsubscribe", notification.NotificationName); - // Observer doesn't exists - return; + Log.Error(ex, $"Notification '{notification.NotificationName}' failed with exception"); } - Observers.Remove(notification.NotificationName); } } } \ No newline at end of file diff --git a/PlexRequests.Services/Notification/NotificationType.cs b/PlexRequests.Services/Notification/NotificationType.cs new file mode 100644 index 000000000..22d0d29b1 --- /dev/null +++ b/PlexRequests.Services/Notification/NotificationType.cs @@ -0,0 +1,38 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: NotificationType.cs +// Created By: Jamie Rees +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// ************************************************************************/ +#endregion +namespace PlexRequests.Services.Notification +{ + public enum NotificationType + { + NewRequest, + Issue, + RequestAvailable, + RequestApproved, + AdminNote, + Test + } +} diff --git a/PlexRequests.Services/Notification/PushbulletNotification.cs b/PlexRequests.Services/Notification/PushbulletNotification.cs index 5000919f7..521855dca 100644 --- a/PlexRequests.Services/Notification/PushbulletNotification.cs +++ b/PlexRequests.Services/Notification/PushbulletNotification.cs @@ -25,12 +25,14 @@ // ************************************************************************/ #endregion using System; +using System.Threading.Tasks; using NLog; using PlexRequests.Api.Interfaces; using PlexRequests.Core; using PlexRequests.Core.SettingModels; +using PlexRequests.Services.Interfaces; namespace PlexRequests.Services.Notification { @@ -39,42 +41,120 @@ namespace PlexRequests.Services.Notification public PushbulletNotification(IPushbulletApi pushbulletApi, ISettingsService settings) { PushbulletApi = pushbulletApi; - Settings = settings; + SettingsService = settings; } - private IPushbulletApi PushbulletApi { get; } - private ISettingsService Settings { get; } + private IPushbulletApi PushbulletApi { get; } + private ISettingsService SettingsService { get; } + private PushbulletNotificationSettings Settings => GetSettings(); private static Logger Log = LogManager.GetCurrentClassLogger(); public string NotificationName => "PushbulletNotification"; - public bool Notify(string title, string requester) + public async Task NotifyAsync(NotificationModel model) { - var settings = GetSettings(); + var configuration = GetSettings(); + await NotifyAsync(model, configuration); + } + + public async Task NotifyAsync(NotificationModel model, Settings settings) + { + if (settings == null) await NotifyAsync(model); + + var pushSettings = (PushbulletNotificationSettings)settings; + + if (!ValidateConfiguration(pushSettings)) return; + switch (model.NotificationType) + { + case NotificationType.NewRequest: + await PushNewRequestAsync(model, pushSettings); + break; + case NotificationType.Issue: + await PushIssueAsync(model, pushSettings); + break; + case NotificationType.RequestAvailable: + break; + case NotificationType.RequestApproved: + break; + case NotificationType.AdminNote: + break; + case NotificationType.Test: + await PushTestAsync(model, pushSettings); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + private bool ValidateConfiguration(PushbulletNotificationSettings settings) + { if (!settings.Enabled) { return false; } + if (string.IsNullOrEmpty(settings.AccessToken)) + { + return false; + } + return true; + } + + private PushbulletNotificationSettings GetSettings() + { + return SettingsService.GetSettings(); + } + + private async Task PushNewRequestAsync(NotificationModel model, PushbulletNotificationSettings settings) + { + var message = $"{model.Title} has been requested by user: {model.User}"; + var pushTitle = $"Plex Requests: {model.Title} has been requested!"; + try + { + var result = await PushbulletApi.PushAsync(settings.AccessToken, pushTitle, message, settings.DeviceIdentifier); + if (result == null) + { + Log.Error("Pushbullet api returned a null value, the notification did not get pushed"); + } + } + catch (Exception e) + { + Log.Error(e); + } + } - var message = $"{title} has been requested by {requester}"; - var pushTitle = $"Plex Requests: {title}"; + private async Task PushIssueAsync(NotificationModel model, PushbulletNotificationSettings settings) + { + var message = $"A new issue: {model.Body} has been reported by user: {model.User} for the title: {model.Title}"; + var pushTitle = $"Plex Requests: A new issue has been reported for {model.Title}"; try { - var result = PushbulletApi.Push(settings.AccessToken, pushTitle, message, settings.DeviceIdentifier); + var result = await PushbulletApi.PushAsync(settings.AccessToken, pushTitle, message, settings.DeviceIdentifier); if (result != null) { - return true; + Log.Error("Pushbullet api returned a null value, the notification did not get pushed"); } } catch (Exception e) { - Log.Fatal(e); + Log.Error(e); } - return false; } - private PushbulletNotificationSettings GetSettings() + private async Task PushTestAsync(NotificationModel model, PushbulletNotificationSettings settings) { - return Settings.GetSettings(); + var message = "This is just a test! Success!"; + var pushTitle = "Plex Requests: Test Message!"; + try + { + var result = await PushbulletApi.PushAsync(settings.AccessToken, pushTitle, message, settings.DeviceIdentifier); + if (result != null) + { + Log.Error("Pushbullet api returned a null value, the notification did not get pushed"); + } + } + catch (Exception e) + { + Log.Error(e); + } } } } \ No newline at end of file diff --git a/PlexRequests.Services/Notification/PushoverNotification.cs b/PlexRequests.Services/Notification/PushoverNotification.cs new file mode 100644 index 000000000..47854b1d5 --- /dev/null +++ b/PlexRequests.Services/Notification/PushoverNotification.cs @@ -0,0 +1,157 @@ +#region Copyright +// /************************************************************************ +// Copyright (c) 2016 Jamie Rees +// File: PushbulletNotification.cs +// Created By: Jamie Rees +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// ************************************************************************/ +#endregion +using System; +using System.Threading.Tasks; + +using NLog; + +using PlexRequests.Api.Interfaces; +using PlexRequests.Core; +using PlexRequests.Core.SettingModels; +using PlexRequests.Services.Interfaces; + +namespace PlexRequests.Services.Notification +{ + public class PushoverNotification : INotification + { + public PushoverNotification(IPushoverApi pushoverApi, ISettingsService settings) + { + PushoverApi = pushoverApi; + SettingsService = settings; + } + private IPushoverApi PushoverApi { get; } + private ISettingsService SettingsService { get; } + private PushoverNotificationSettings Settings => GetSettings(); + + private static Logger Log = LogManager.GetCurrentClassLogger(); + public string NotificationName => "PushoverNotification"; + public async Task NotifyAsync(NotificationModel model) + { + var configuration = GetSettings(); + await NotifyAsync(model, configuration); + } + + public async Task NotifyAsync(NotificationModel model, Settings settings) + { + if (settings == null) await NotifyAsync(model); + + var pushSettings = (PushoverNotificationSettings)settings; + + if (!ValidateConfiguration(pushSettings)) return; + + switch (model.NotificationType) + { + case NotificationType.NewRequest: + await PushNewRequestAsync(model, pushSettings); + break; + case NotificationType.Issue: + await PushIssueAsync(model, pushSettings); + break; + case NotificationType.RequestAvailable: + break; + case NotificationType.RequestApproved: + break; + case NotificationType.AdminNote: + break; + case NotificationType.Test: + await PushTestAsync(model, pushSettings); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + private bool ValidateConfiguration(PushoverNotificationSettings settings) + { + if (!settings.Enabled) + { + return false; + } + if (string.IsNullOrEmpty(settings.AccessToken) || string.IsNullOrEmpty(settings.UserToken)) + { + return false; + } + return true; + } + + private PushoverNotificationSettings GetSettings() + { + return SettingsService.GetSettings(); + } + + private async Task PushNewRequestAsync(NotificationModel model, PushoverNotificationSettings settings) + { + var message = $"Plex Requests: {model.Title} has been requested by user: {model.User}"; + try + { + var result = await PushoverApi.PushAsync(settings.AccessToken, message, settings.UserToken); + if (result?.status != 1) + { + Log.Error("Pushover api returned a status that was not 1, the notification did not get pushed"); + } + } + catch (Exception e) + { + Log.Error(e); + } + } + + private async Task PushIssueAsync(NotificationModel model, PushoverNotificationSettings settings) + { + var message = $"Plex Requests: A new issue: {model.Body} has been reported by user: {model.User} for the title: {model.Title}"; + try + { + var result = await PushoverApi.PushAsync(settings.AccessToken, message, settings.UserToken); + if (result?.status != 1) + { + Log.Error("Pushover api returned a status that was not 1, the notification did not get pushed"); + } + } + catch (Exception e) + { + Log.Error(e); + } + } + + private async Task PushTestAsync(NotificationModel model, PushoverNotificationSettings settings) + { + var message = $"Plex Requests: Test Message!"; + try + { + var result = await PushoverApi.PushAsync(settings.AccessToken, message, settings.UserToken); + if (result?.status != 1) + { + Log.Error("Pushover api returned a status that was not 1, the notification did not get pushed"); + } + } + catch (Exception e) + { + Log.Error(e); + } + } + } +} \ No newline at end of file diff --git a/PlexRequests.Services/PlexAvailabilityChecker.cs b/PlexRequests.Services/PlexAvailabilityChecker.cs index bc1cfb8e7..a71b75251 100644 --- a/PlexRequests.Services/PlexAvailabilityChecker.cs +++ b/PlexRequests.Services/PlexAvailabilityChecker.cs @@ -24,14 +24,17 @@ // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // ************************************************************************/ #endregion +using System; using System.Collections.Generic; using System.Linq; using NLog; using PlexRequests.Api.Interfaces; +using PlexRequests.Api.Models.Plex; using PlexRequests.Core; using PlexRequests.Core.SettingModels; +using PlexRequests.Helpers; using PlexRequests.Helpers.Exceptions; using PlexRequests.Services.Interfaces; using PlexRequests.Store; @@ -52,33 +55,77 @@ namespace PlexRequests.Services private ISettingsService Auth { get; } private IRequestService RequestService { get; } private static Logger Log = LogManager.GetCurrentClassLogger(); - private IPlexApi PlexApi { get; set; } + private IPlexApi PlexApi { get; } public void CheckAndUpdateAll(long check) { + Log.Trace("This is check no. {0}", check); + Log.Trace("Getting the settings"); var plexSettings = Plex.GetSettings(); var authSettings = Auth.GetSettings(); + Log.Trace("Getting all the requests"); var requests = RequestService.GetAll(); - var requestedModels = requests as RequestedModel[] ?? requests.ToArray(); - if (!ValidateSettings(plexSettings, authSettings, requestedModels)) + var requestedModels = requests as RequestedModel[] ?? requests.Where(x => !x.Available).ToArray(); + Log.Trace("Requests Count {0}", requestedModels.Length); + + if (!ValidateSettings(plexSettings, authSettings) || !requestedModels.Any()) { + Log.Info("Validation of the settings failed or there is no requests."); return; } var modifiedModel = new List(); foreach (var r in requestedModels) { - var results = PlexApi.SearchContent(authSettings.PlexAuthToken, r.Title, plexSettings.FullUri); - var result = results.Video.FirstOrDefault(x => x.Title == r.Title); - var originalRequest = RequestService.Get(r.Id); + Log.Trace("We are going to see if Plex has the following title: {0}", r.Title); + PlexSearch results; + try + { + results = PlexApi.SearchContent(authSettings.PlexAuthToken, r.Title, plexSettings.FullUri); + } + catch (Exception e) + { + Log.Error("We failed to search Plex for the following request:"); + Log.Error(r.DumpJson()); + Log.Error(e); + break; // Let's finish processing and not crash the process, there is a reason why we cannot connect. + } + + if (results == null) + { + Log.Trace("Could not find any matching result for this title."); + continue; + } + + Log.Trace("Search results from Plex for the following request: {0}", r.Title); + Log.Trace(results.DumpJson()); + + var videoResult = results.Video.FirstOrDefault(x => x.Title == r.Title); + var directoryResult = results.Directory?.Title.Equals(r.Title, StringComparison.CurrentCultureIgnoreCase); + + Log.Trace("The result from Plex where the title matches for the video : {0}", videoResult != null); + Log.Trace("The result from Plex where the title matches for the directory : {0}", directoryResult != null); - originalRequest.Available = result != null; - modifiedModel.Add(originalRequest); + var directoryResultVal = directoryResult ?? false; + + if (videoResult != null || directoryResultVal) + { + r.Available = true; + modifiedModel.Add(r); + continue; + } + + Log.Trace("The result from Plex where the title's match was null, so that means the content is not yet in Plex."); } - RequestService.BatchUpdate(modifiedModel); + Log.Trace("Updating the requests now"); + Log.Trace("Requests that will be updates:"); + Log.Trace(modifiedModel.SelectMany(x => x.Title).DumpJson()); + + if(modifiedModel.Any()) + { RequestService.BatchUpdate(modifiedModel);} } /// @@ -90,45 +137,31 @@ namespace PlexRequests.Services /// The settings are not configured for Plex or Authentication public bool IsAvailable(string title, string year) { + Log.Trace("Checking if the following {0} {1} is available in Plex", title, year); var plexSettings = Plex.GetSettings(); var authSettings = Auth.GetSettings(); if (!ValidateSettings(plexSettings, authSettings)) { + Log.Warn("The settings are not configured"); throw new ApplicationSettingsException("The settings are not configured for Plex or Authentication"); } + var results = PlexApi.SearchContent(authSettings.PlexAuthToken, title, plexSettings.FullUri); if (!string.IsNullOrEmpty(year)) { - var results = PlexApi.SearchContent(authSettings.PlexAuthToken, title, plexSettings.FullUri); - var result = results.Video?.FirstOrDefault(x => x.Title.Contains(title) && x.Year == year); - var directoryTitle = results.Directory?.Title == title && results.Directory?.Year == year; + var result = results.Video?.FirstOrDefault(x => x.Title.Equals(title, StringComparison.InvariantCultureIgnoreCase) && x.Year == year); + var directoryTitle = string.Equals(results.Directory?.Title, title, StringComparison.CurrentCultureIgnoreCase) && results.Directory?.Year == year; return result?.Title != null || directoryTitle; } else { - var results = PlexApi.SearchContent(authSettings.PlexAuthToken, title, plexSettings.FullUri); - var result = results.Video?.FirstOrDefault(x => x.Title.Contains(title)); - var directoryTitle = results.Directory?.Title == title; + var result = results.Video?.FirstOrDefault(x => x.Title.Equals(title, StringComparison.InvariantCultureIgnoreCase)); + var directoryTitle = string.Equals(results.Directory?.Title, title, StringComparison.CurrentCultureIgnoreCase); return result?.Title != null || directoryTitle; } } - private bool ValidateSettings(PlexSettings plex, AuthenticationSettings auth, IEnumerable requests) - { - if (plex.Ip == null || auth.PlexAuthToken == null || requests == null) - { - Log.Warn("A setting is null, Ensure Plex is configured correctly, and we have a Plex Auth token."); - return false; - } - if (!requests.Any()) - { - Log.Info("We have no requests to check if they are available on Plex."); - return false; - } - return true; - } - private bool ValidateSettings(PlexSettings plex, AuthenticationSettings auth) { if (plex?.Ip == null || auth?.PlexAuthToken == null) diff --git a/PlexRequests.Services/PlexRequests.Services.csproj b/PlexRequests.Services/PlexRequests.Services.csproj index 29ad390d5..bbb334ca9 100644 --- a/PlexRequests.Services/PlexRequests.Services.csproj +++ b/PlexRequests.Services/PlexRequests.Services.csproj @@ -77,9 +77,13 @@ - + + + + + diff --git a/PlexRequests.Services/UpdateInterval.cs b/PlexRequests.Services/UpdateInterval.cs index 876b9e379..92045563a 100644 --- a/PlexRequests.Services/UpdateInterval.cs +++ b/PlexRequests.Services/UpdateInterval.cs @@ -32,7 +32,7 @@ namespace PlexRequests.Services { public class UpdateInterval : IIntervals { - public TimeSpan Notification => TimeSpan.FromMinutes(5); + public TimeSpan Notification => TimeSpan.FromMinutes(10); } } \ No newline at end of file diff --git a/PlexRequests.Store/DbConfiguration.cs b/PlexRequests.Store/DbConfiguration.cs index 7b6c6c244..7ddd60483 100644 --- a/PlexRequests.Store/DbConfiguration.cs +++ b/PlexRequests.Store/DbConfiguration.cs @@ -27,12 +27,11 @@ using System; using System.Data; using System.IO; +using System.Windows.Forms; using Mono.Data.Sqlite; using NLog; -using PlexRequests.Helpers; -using PlexRequests.Store.Repository; namespace PlexRequests.Store { @@ -44,12 +43,14 @@ namespace PlexRequests.Store Factory = provider; } - private SqliteFactory Factory { get; set; } + private SqliteFactory Factory { get; } + private string CurrentPath =>Path.Combine(Path.GetDirectoryName(Application.ExecutablePath) ?? string.Empty, DbFile); public virtual bool CheckDb() { Log.Trace("Checking DB"); - if (!File.Exists(DbFile)) + Console.WriteLine("Location of the database: {0}",CurrentPath); + if (!File.Exists(CurrentPath)) { Log.Trace("DB doesn't exist, creating a new one"); CreateDatabase(); @@ -59,7 +60,7 @@ namespace PlexRequests.Store } public string DbFile = "PlexRequests.sqlite"; - + /// /// Gets the database connection. /// @@ -72,7 +73,7 @@ namespace PlexRequests.Store { throw new SqliteException("Factory returned null"); } - fact.ConnectionString = "Data Source=" + DbFile; + fact.ConnectionString = "Data Source=" + CurrentPath; return fact; } @@ -83,14 +84,16 @@ namespace PlexRequests.Store { try { - using (File.Create(DbFile)) + using (File.Create(CurrentPath)) { } } catch (Exception e) { - Console.WriteLine(e.Message); + Log.Error(e); } } + + } } diff --git a/PlexRequests.Store/Models/LogEntity.cs b/PlexRequests.Store/Models/LogEntity.cs index 694b6ce8d..d1d45cfc1 100644 --- a/PlexRequests.Store/Models/LogEntity.cs +++ b/PlexRequests.Store/Models/LogEntity.cs @@ -26,16 +26,22 @@ #endregion using System; +using Dapper.Contrib.Extensions; +using Newtonsoft.Json; + namespace PlexRequests.Store.Models { + [Table("Logs")] public class LogEntity : Entity { - public string Username { get; set; } public DateTime Date { get; set; } public string Level { get; set; } public string Logger { get; set; } public string Message { get; set; } public string Callsite { get; set; } public string Exception { get; set; } + + [JsonIgnore] + public string DateString { get; set; } } } diff --git a/PlexRequests.Store/Models/RequestBlobs.cs b/PlexRequests.Store/Models/RequestBlobs.cs index 3b1127b6a..f9af75a25 100644 --- a/PlexRequests.Store/Models/RequestBlobs.cs +++ b/PlexRequests.Store/Models/RequestBlobs.cs @@ -34,5 +34,6 @@ namespace PlexRequests.Store.Models public int ProviderId { get; set; } public byte[] Content { get; set; } public RequestType Type { get; set; } + public string MusicId { get; set; } } } \ No newline at end of file diff --git a/PlexRequests.Store/PlexRequests.Store.csproj b/PlexRequests.Store/PlexRequests.Store.csproj index d9586aba3..a896a0ee6 100644 --- a/PlexRequests.Store/PlexRequests.Store.csproj +++ b/PlexRequests.Store/PlexRequests.Store.csproj @@ -42,12 +42,17 @@ ..\Assemblies\Mono.Data.Sqlite.dll + + ..\packages\Newtonsoft.Json.8.0.2\lib\net45\Newtonsoft.Json.dll + True + ..\packages\NLog.4.2.3\lib\net45\NLog.dll True + @@ -58,17 +63,17 @@ - - + + - + - + diff --git a/PlexRequests.Store/GenericRepository.cs b/PlexRequests.Store/Repository/GenericRepository.cs similarity index 77% rename from PlexRequests.Store/GenericRepository.cs rename to PlexRequests.Store/Repository/GenericRepository.cs index 331567463..17e7b9c0f 100644 --- a/PlexRequests.Store/GenericRepository.cs +++ b/PlexRequests.Store/Repository/GenericRepository.cs @@ -34,20 +34,23 @@ using NLog; using PlexRequests.Helpers; -namespace PlexRequests.Store +namespace PlexRequests.Store.Repository { public class GenericRepository : IRepository where T : Entity { - public GenericRepository(ISqliteConfiguration config) + private ICacheProvider Cache { get; } + public GenericRepository(ISqliteConfiguration config, ICacheProvider cache) { Config = config; + Cache = cache; } private static Logger Log = LogManager.GetCurrentClassLogger(); - private ISqliteConfiguration Config { get; set; } + private ISqliteConfiguration Config { get; } public long Insert(T entity) { + ResetCache(); using (var cnn = Config.DbConnection()) { cnn.Open(); @@ -57,12 +60,14 @@ namespace PlexRequests.Store public IEnumerable GetAll() { + using (var db = Config.DbConnection()) { db.Open(); var result = db.GetAll(); return result; } + } public T Get(string id) @@ -72,15 +77,23 @@ namespace PlexRequests.Store public T Get(int id) { - using (var db = Config.DbConnection()) - { - db.Open(); - return db.Get(id); - } + var key = "Get" + id; + var item = Cache.GetOrSet( + key, + () => + { + using (var db = Config.DbConnection()) + { + db.Open(); + return db.Get(id); + } + }); + return item; } public void Delete(T entity) { + ResetCache(); using (var db = Config.DbConnection()) { db.Open(); @@ -90,6 +103,7 @@ namespace PlexRequests.Store public bool Update(T entity) { + ResetCache(); Log.Trace("Updating entity"); Log.Trace(entity.DumpJson()); using (var db = Config.DbConnection()) @@ -101,6 +115,7 @@ namespace PlexRequests.Store public bool UpdateAll(IEnumerable entity) { + ResetCache(); Log.Trace("Updating all entities"); var result = new HashSet(); @@ -114,5 +129,11 @@ namespace PlexRequests.Store } return result.All(x => true); } + + private void ResetCache() + { + Cache.Remove("Get"); + Cache.Remove("GetAll"); + } } } diff --git a/PlexRequests.Store/IRepository.cs b/PlexRequests.Store/Repository/IRepository.cs similarity index 95% rename from PlexRequests.Store/IRepository.cs rename to PlexRequests.Store/Repository/IRepository.cs index d88f80aa0..4d301047c 100644 --- a/PlexRequests.Store/IRepository.cs +++ b/PlexRequests.Store/Repository/IRepository.cs @@ -26,7 +26,7 @@ #endregion using System.Collections.Generic; -namespace PlexRequests.Store +namespace PlexRequests.Store.Repository { public interface IRepository { diff --git a/PlexRequests.Store/IRequestRepository.cs b/PlexRequests.Store/Repository/IRequestRepository.cs similarity index 95% rename from PlexRequests.Store/IRequestRepository.cs rename to PlexRequests.Store/Repository/IRequestRepository.cs index e2a07b6b5..809628c51 100644 --- a/PlexRequests.Store/IRequestRepository.cs +++ b/PlexRequests.Store/Repository/IRequestRepository.cs @@ -28,7 +28,7 @@ using System.Collections.Generic; using PlexRequests.Store.Models; -namespace PlexRequests.Store +namespace PlexRequests.Store.Repository { public interface IRequestRepository { diff --git a/PlexRequests.Store/ISettingsRepository.cs b/PlexRequests.Store/Repository/ISettingsRepository.cs similarity index 95% rename from PlexRequests.Store/ISettingsRepository.cs rename to PlexRequests.Store/Repository/ISettingsRepository.cs index c0a8a866a..393da6ef7 100644 --- a/PlexRequests.Store/ISettingsRepository.cs +++ b/PlexRequests.Store/Repository/ISettingsRepository.cs @@ -28,7 +28,7 @@ using System.Collections.Generic; using PlexRequests.Store.Models; -namespace PlexRequests.Store +namespace PlexRequests.Store.Repository { public interface ISettingsRepository { diff --git a/PlexRequests.Store/Repository/RequestJsonRepository.cs b/PlexRequests.Store/Repository/RequestJsonRepository.cs index d02a111b0..872e07745 100644 --- a/PlexRequests.Store/Repository/RequestJsonRepository.cs +++ b/PlexRequests.Store/Repository/RequestJsonRepository.cs @@ -37,13 +37,11 @@ namespace PlexRequests.Store.Repository public class RequestJsonRepository : IRequestRepository { private ICacheProvider Cache { get; } - - private string TypeName { get; } + public RequestJsonRepository(ISqliteConfiguration config, ICacheProvider cacheProvider) { Db = config; Cache = cacheProvider; - TypeName = typeof(RequestJsonRepository).Name; } private ISqliteConfiguration Db { get; } @@ -60,7 +58,7 @@ namespace PlexRequests.Store.Repository public IEnumerable GetAll() { - var key = TypeName + "GetAll"; + var key = "GetAll"; var item = Cache.GetOrSet(key, () => { using (var con = Db.DbConnection()) @@ -74,7 +72,7 @@ namespace PlexRequests.Store.Repository public RequestBlobs Get(int id) { - var key = TypeName + "Get" + id; + var key = "Get" + id; var item = Cache.GetOrSet(key, () => { using (var con = Db.DbConnection()) @@ -107,7 +105,7 @@ namespace PlexRequests.Store.Repository private void ResetCache() { Cache.Remove("Get"); - Cache.Remove(TypeName + "GetAll"); + Cache.Remove("GetAll"); } public bool UpdateAll(IEnumerable entity) diff --git a/PlexRequests.Store/RequestedModel.cs b/PlexRequests.Store/RequestedModel.cs index 5f746b841..55fc3abac 100644 --- a/PlexRequests.Store/RequestedModel.cs +++ b/PlexRequests.Store/RequestedModel.cs @@ -1,13 +1,19 @@ using System; -using System.Security.Cryptography; - using Dapper.Contrib.Extensions; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; namespace PlexRequests.Store { [Table("Requested")] public class RequestedModel : Entity { + public RequestedModel() + { + RequestedUsers = new List(); + } + // ReSharper disable once IdentifierTypo public int ProviderId { get; set; } public string ImdbId { get; set; } @@ -18,7 +24,10 @@ namespace PlexRequests.Store public RequestType Type { get; set; } public string Status { get; set; } public bool Approved { get; set; } + + [Obsolete("Use RequestedUsers")] public string RequestedBy { get; set; } + public DateTime RequestedDate { get; set; } public bool Available { get; set; } public IssueState Issues { get; set; } @@ -26,12 +35,49 @@ namespace PlexRequests.Store public string AdminNote { get; set; } public int[] SeasonList { get; set; } public int SeasonCount { get; set; } + public string SeasonsRequested { get; set; } + public string MusicBrainzId { get; set; } + public List RequestedUsers { get; set; } + + [JsonIgnore] + public List AllUsers + { + get + { + var u = new List(); + if (!string.IsNullOrEmpty(RequestedBy)) + { + u.Add(RequestedBy); + } + + if (RequestedUsers.Any()) + { + u.AddRange(RequestedUsers.Where(requestedUser => requestedUser != RequestedBy)); + } + return u; + } + } + + [JsonIgnore] + public bool CanApprove + { + get + { + return !Approved && !Available; + } + } + + public bool UserHasRequested(string username) + { + return AllUsers.Any(x => x.Equals(username, StringComparison.OrdinalIgnoreCase)); + } } public enum RequestType { Movie, - TvShow + TvShow, + Album } public enum IssueState diff --git a/PlexRequests.Store/SqlTables.sql b/PlexRequests.Store/SqlTables.sql index 1a2f5b32a..7392c4efc 100644 --- a/PlexRequests.Store/SqlTables.sql +++ b/PlexRequests.Store/SqlTables.sql @@ -6,7 +6,9 @@ CREATE TABLE IF NOT EXISTS Users UserGuid varchar(50) NOT NULL , UserName varchar(50) NOT NULL, Salt BLOB NOT NULL, - Hash BLOB NOT NULL + Hash BLOB NOT NULL, + Claims BLOB NOT NULL, + UserProperties BLOB ); @@ -16,20 +18,21 @@ CREATE TABLE IF NOT EXISTS GlobalSettings SettingsName varchar(50) NOT NULL, Content varchar(100) NOT NULL ); +CREATE UNIQUE INDEX IF NOT EXISTS GlobalSettings_Id ON GlobalSettings (Id); CREATE TABLE IF NOT EXISTS RequestBlobs ( Id INTEGER PRIMARY KEY AUTOINCREMENT, ProviderId INTEGER NOT NULL, Type INTEGER NOT NULL, - Content BLOB NOT NULL + Content BLOB NOT NULL, + MusicId TEXT ); +CREATE UNIQUE INDEX IF NOT EXISTS RequestBlobs_Id ON RequestBlobs (Id); - -CREATE TABLE IF NOT EXISTS Log +CREATE TABLE IF NOT EXISTS Logs ( Id INTEGER PRIMARY KEY AUTOINCREMENT, - Username varchar(50) NOT NULL, Date varchar(100) NOT NULL, Level varchar(100) NOT NULL, Logger varchar(100) NOT NULL, @@ -37,3 +40,10 @@ CREATE TABLE IF NOT EXISTS Log CallSite varchar(100) NOT NULL, Exception varchar(100) NOT NULL ); +CREATE UNIQUE INDEX IF NOT EXISTS Logs_Id ON Logs (Id); + +CREATE TABLE IF NOT EXISTS DBInfo +( + SchemaVersion INTEGER + +); \ No newline at end of file diff --git a/PlexRequests.Store/TableCreation.cs b/PlexRequests.Store/TableCreation.cs index 437105ffc..6b0e07044 100644 --- a/PlexRequests.Store/TableCreation.cs +++ b/PlexRequests.Store/TableCreation.cs @@ -25,7 +25,7 @@ // *********************************************************************** #endregion using System.Data; - +using System.Linq; using Dapper; using Dapper.Contrib.Extensions; @@ -44,6 +44,57 @@ namespace PlexRequests.Store connection.Close(); } + public static void AlterTable(IDbConnection connection, string tableName, string alterType, string newColumn, bool isNullable, string dataType) + { + connection.Open(); + var result = connection.Query($"PRAGMA table_info({tableName});"); + if (result.Any(x => x.name == newColumn)) + { + return; + } + + var query = $"ALTER TABLE {tableName} {alterType} {newColumn} {dataType}"; + if (isNullable) + { + query = query + " NOT NULL"; + } + + connection.Execute(query); + + connection.Close(); + } + + public static DbInfo GetSchemaVersion(this IDbConnection con) + { + con.Open(); + var result = con.Query("SELECT * FROM DBInfo"); + con.Close(); + + return result.FirstOrDefault(); + } + + public static void UpdateSchemaVersion(this IDbConnection con, int version) + { + con.Open(); + con.Query($"UPDATE DBInfo SET SchemaVersion = {version}"); + con.Close(); + } + + public static void CreateSchema(this IDbConnection con) + { + con.Open(); + con.Query("INSERT INTO DBInfo (SchemaVersion) values (0)"); + con.Close(); + } + + + + [Table("DBInfo")] + public class DbInfo + { + public int SchemaVersion { get; set; } + } + [Table("sqlite_master")] public class SqliteMasterTable { @@ -54,5 +105,17 @@ namespace PlexRequests.Store public long rootpage { get; set; } public string sql { get; set; } } + + [Table("table_info")] + public class TableInfo + { + public int cid { get; set; } + public string name { get; set; } + public int notnull { get; set; } + public string dflt_value { get; set; } + public int pk { get; set; } + } + + } } diff --git a/PlexRequests.Store/UserRepository.cs b/PlexRequests.Store/UserRepository.cs index 3d431908e..467e0487c 100644 --- a/PlexRequests.Store/UserRepository.cs +++ b/PlexRequests.Store/UserRepository.cs @@ -30,6 +30,8 @@ using System.Linq; using Dapper.Contrib.Extensions; +using PlexRequests.Store.Repository; + namespace PlexRequests.Store { public class UserRepository : IRepository where T : UserEntity diff --git a/PlexRequests.Store/UsersModel.cs b/PlexRequests.Store/UsersModel.cs index e0376b7ba..8a3753c6c 100644 --- a/PlexRequests.Store/UsersModel.cs +++ b/PlexRequests.Store/UsersModel.cs @@ -33,5 +33,7 @@ namespace PlexRequests.Store { public byte[] Hash { get; set; } public byte[] Salt { get; set; } + public byte[] Claims { get; set; } + public byte[] UserProperties { get; set; } } } diff --git a/PlexRequests.Store/packages.config b/PlexRequests.Store/packages.config index 6ecb34bd0..e6a6a64fa 100644 --- a/PlexRequests.Store/packages.config +++ b/PlexRequests.Store/packages.config @@ -2,5 +2,6 @@ + \ No newline at end of file diff --git a/PlexRequests.UI.Tests/AdminModuleTests.cs b/PlexRequests.UI.Tests/AdminModuleTests.cs index e42eb4a13..34b2af4a7 100644 --- a/PlexRequests.UI.Tests/AdminModuleTests.cs +++ b/PlexRequests.UI.Tests/AdminModuleTests.cs @@ -39,6 +39,9 @@ using PlexRequests.Api.Interfaces; using PlexRequests.Api.Models.Plex; using PlexRequests.Core; using PlexRequests.Core.SettingModels; +using PlexRequests.Services.Interfaces; +using PlexRequests.Store.Models; +using PlexRequests.Store.Repository; using PlexRequests.UI.Models; using PlexRequests.UI.Modules; @@ -55,10 +58,15 @@ namespace PlexRequests.UI.Tests private Mock> SickRageSettingsMock { get; set; } private Mock> EmailMock { get; set; } private Mock> PushbulletSettings { get; set; } + private Mock> PushoverSettings { get; set; } + private Mock> HeadphonesSettings { get; set; } private Mock PlexMock { get; set; } private Mock SonarrApiMock { get; set; } private Mock PushbulletApi { get; set; } + private Mock PushoverApi { get; set; } private Mock CpApi { get; set; } + private Mock> LogRepo { get; set; } + private Mock NotificationService { get; set; } private ConfigurableBootstrapper Bootstrapper { get; set; } @@ -83,6 +91,11 @@ namespace PlexRequests.UI.Tests PushbulletSettings = new Mock>(); CpApi = new Mock(); SickRageSettingsMock = new Mock>(); + LogRepo = new Mock>(); + PushoverSettings = new Mock>(); + PushoverApi = new Mock(); + NotificationService = new Mock(); + HeadphonesSettings = new Mock>(); Bootstrapper = new ConfigurableBootstrapper(with => { @@ -99,6 +112,11 @@ namespace PlexRequests.UI.Tests with.Dependency(PushbulletSettings.Object); with.Dependency(CpApi.Object); with.Dependency(SickRageSettingsMock.Object); + with.Dependency(LogRepo.Object); + with.Dependency(PushoverSettings.Object); + with.Dependency(PushoverApi.Object); + with.Dependency(NotificationService.Object); + with.Dependency(HeadphonesSettings.Object); with.RootPathProvider(); with.RequestStartup((container, pipelines, context) => { diff --git a/PlexRequests.UI.Tests/PlexRequests.UI.Tests.csproj b/PlexRequests.UI.Tests/PlexRequests.UI.Tests.csproj index 96823f840..e79b01dc4 100644 --- a/PlexRequests.UI.Tests/PlexRequests.UI.Tests.csproj +++ b/PlexRequests.UI.Tests/PlexRequests.UI.Tests.csproj @@ -117,6 +117,10 @@ {566EFA49-68F8-4716-9693-A6B3F2624DEA} PlexRequests.Services + + {92433867-2B7B-477B-A566-96C382427525} + PlexRequests.Store + {68F5F5F3-B8BB-4911-875F-6F00AAE04EA6} PlexRequests.UI diff --git a/PlexRequests.UI/Bootstrapper.cs b/PlexRequests.UI/Bootstrapper.cs index 8fd5aad2b..a4e13f18b 100644 --- a/PlexRequests.UI/Bootstrapper.cs +++ b/PlexRequests.UI/Bootstrapper.cs @@ -47,6 +47,7 @@ using PlexRequests.Services; using PlexRequests.Services.Interfaces; using PlexRequests.Services.Notification; using PlexRequests.Store; +using PlexRequests.Store.Models; using PlexRequests.Store.Repository; using PlexRequests.UI.Jobs; using TaskFactory = FluentScheduler.TaskFactory; @@ -74,9 +75,11 @@ namespace PlexRequests.UI container.Register, SettingsServiceV2>(); container.Register, SettingsServiceV2>(); container.Register, SettingsServiceV2>(); + container.Register, SettingsServiceV2>(); + container.Register, SettingsServiceV2>(); // Repo's - container.Register, GenericRepository>(); + container.Register, GenericRepository>(); container.Register(); container.Register(); @@ -88,19 +91,25 @@ namespace PlexRequests.UI // Api's container.Register(); container.Register(); + container.Register(); container.Register(); container.Register(); container.Register(); + container.Register(); + container.Register(); + + // NotificationService + container.Register().AsSingleton(); SubscribeAllObservers(container); base.ConfigureRequestContainer(container, context); - } - protected override void ApplicationStartup(TinyIoCContainer container, IPipelines pipelines) - { TaskManager.TaskFactory = new PlexTaskFactory(); TaskManager.Initialize(new PlexRegistry()); + } + protected override void ApplicationStartup(TinyIoCContainer container, IPipelines pipelines) + { CookieBasedSessions.Enable(pipelines, CryptographyConfiguration.Default); StaticConfiguration.DisableErrorTraces = false; @@ -116,28 +125,38 @@ namespace PlexRequests.UI FormsAuthentication.Enable(pipelines, formsAuthConfiguration); + ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls; ServicePointManager.ServerCertificateValidationCallback += (sender, certificate, chain, sslPolicyErrors) => true; } - + protected override DiagnosticsConfiguration DiagnosticsConfiguration => new DiagnosticsConfiguration { Password = @"password" }; private void SubscribeAllObservers(TinyIoCContainer container) { + var notificationService = container.Resolve(); + var emailSettingsService = container.Resolve>(); var emailSettings = emailSettingsService.GetSettings(); if (emailSettings.Enabled) { - NotificationService.Subscribe(new EmailMessageNotification(emailSettingsService)); + notificationService.Subscribe(new EmailMessageNotification(emailSettingsService)); } var pushbulletService = container.Resolve>(); var pushbulletSettings = pushbulletService.GetSettings(); if (pushbulletSettings.Enabled) { - NotificationService.Subscribe(new PushbulletNotification(container.Resolve(), container.Resolve>())); + notificationService.Subscribe(new PushbulletNotification(container.Resolve(), pushbulletService)); + } + + var pushoverService = container.Resolve>(); + var pushoverSettings = pushoverService.GetSettings(); + if (pushoverSettings.Enabled) + { + notificationService.Subscribe(new PushoverNotification(container.Resolve(), pushoverService)); } } } diff --git a/PlexRequests.UI/Content/bootstrap.css b/PlexRequests.UI/Content/bootstrap.css index 7b3046590..83e3b6fa3 100644 --- a/PlexRequests.UI/Content/bootstrap.css +++ b/PlexRequests.UI/Content/bootstrap.css @@ -269,8 +269,8 @@ th { } @font-face { font-family: 'Glyphicons Halflings'; - src: url('../fonts/glyphicons-halflings-regular.eot'); - src: url('../fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'), url('../fonts/glyphicons-halflings-regular.woff2') format('woff2'), url('../fonts/glyphicons-halflings-regular.woff') format('woff'), url('../fonts/glyphicons-halflings-regular.ttf') format('truetype'), url('../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg'); + src: url('../Content/fonts/glyphicons-halflings-regular.eot'); + src: url('../Content/fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'), url('../Content/fonts/glyphicons-halflings-regular.woff2') format('woff2'), url('../Content/fonts/glyphicons-halflings-regular.woff') format('woff'), url('../Content/fonts/glyphicons-halflings-regular.ttf') format('truetype'), url('../Content/fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg'); } .glyphicon { position: relative; diff --git a/PlexRequests.UI/Content/custom.css b/PlexRequests.UI/Content/custom.css index a517cc508..66153a87a 100644 --- a/PlexRequests.UI/Content/custom.css +++ b/PlexRequests.UI/Content/custom.css @@ -22,7 +22,9 @@ .form-control-custom { background-color: #4e5d6c !important; - color: white !important; } + color: white !important; + border-radius: 0; + box-shadow: 0 0 0 !important; } h1 { font-size: 3.5rem !important; @@ -40,6 +42,22 @@ label { margin-bottom: 0.5rem !important; font-size: 16px !important; } +.nav-tabs > li.active > a, +.nav-tabs > li.active > a:hover, +.nav-tabs > li.active > a:focus { + background: #4e5d6c; } + +.navbar .nav a .fa, +.dropdown-menu a .fa { + font-size: 130%; + top: 1px; + position: relative; + display: inline-block; + margin-right: 5px; } + +.dropdown-menu a .fa { + top: 2px; } + .btn-danger-outline { color: #d9534f !important; background-color: transparent; @@ -126,3 +144,68 @@ label { #tvList .mix { display: none; } +.scroll-top-wrapper { + position: fixed; + opacity: 0; + visibility: hidden; + overflow: hidden; + text-align: center; + z-index: 99999999; + background-color: #4e5d6c; + color: #eeeeee; + width: 50px; + height: 48px; + line-height: 48px; + right: 30px; + bottom: 30px; + padding-top: 2px; + border-top-left-radius: 10px; + border-top-right-radius: 10px; + border-bottom-right-radius: 10px; + border-bottom-left-radius: 10px; + -webkit-transition: all 0.5s ease-in-out; + -moz-transition: all 0.5s ease-in-out; + -ms-transition: all 0.5s ease-in-out; + -o-transition: all 0.5s ease-in-out; + transition: all 0.5s ease-in-out; } + +.scroll-top-wrapper:hover { + background-color: #637689; } + +.scroll-top-wrapper.show { + visibility: visible; + cursor: pointer; + opacity: 1.0; } + +.scroll-top-wrapper i.fa { + line-height: inherit; } + +.no-search-results { + text-align: center; } + +.no-search-results .no-search-results-icon { + font-size: 10em; + color: #4e5d6c; } + +.no-search-results .no-search-results-text { + margin: 20px 0; + color: #ccc; } + +.form-control-search { + padding: 25px 105px 25px 16px; } + +.form-control-withbuttons { + padding-right: 105px; } + +.input-group-addon .btn-group { + position: absolute; + right: 45px; + z-index: 3; + top: 13px; + box-shadow: 0 0 0; } + +.input-group-addon .btn-group .btn { + border: 1px solid rgba(255, 255, 255, 0.7) !important; + padding: 3px 12px; + color: rgba(255, 255, 255, 0.7) !important; } + diff --git a/PlexRequests.UI/Content/custom.min.css b/PlexRequests.UI/Content/custom.min.css index 45dc19440..9f60b69e2 100644 --- a/PlexRequests.UI/Content/custom.min.css +++ b/PlexRequests.UI/Content/custom.min.css @@ -1 +1 @@ -@media(min-width:768px){.row{position:relative;}.bottom-align-text{position:absolute;bottom:0;right:0;}}@media(max-width:48em){.home{padding-top:1rem;}}@media(min-width:48em){.home{padding-top:4rem;}}.btn{border-radius:.25rem !important;}.multiSelect{background-color:#4e5d6c;}.form-control-custom{background-color:#4e5d6c !important;color:#fff !important;}h1{font-size:3.5rem !important;font-weight:600 !important;}.request-title{margin-top:0 !important;font-size:1.9rem !important;}p{font-size:1.1rem !important;}label{display:inline-block !important;margin-bottom:.5rem !important;font-size:16px !important;}.btn-danger-outline{color:#d9534f !important;background-color:transparent;background-image:none;border-color:#d9534f !important;}.btn-danger-outline:focus,.btn-danger-outline.focus,.btn-danger-outline:active,.btn-danger-outline.active,.btn-danger-outline:hover,.open>.btn-danger-outline.dropdown-toggle{color:#fff !important;background-color:#d9534f !important;border-color:#d9534f !important;}.btn-primary-outline{color:#ff761b !important;background-color:transparent;background-image:none;border-color:#ff761b !important;}.btn-primary-outline:focus,.btn-primary-outline.focus,.btn-primary-outline:active,.btn-primary-outline.active,.btn-primary-outline:hover,.open>.btn-primary-outline.dropdown-toggle{color:#fff !important;background-color:#df691a !important;border-color:#df691a !important;}.btn-info-outline{color:#5bc0de !important;background-color:transparent;background-image:none;border-color:#5bc0de !important;}.btn-info-outline:focus,.btn-info-outline.focus,.btn-info-outline:active,.btn-info-outline.active,.btn-info-outline:hover,.open>.btn-info-outline.dropdown-toggle{color:#fff !important;background-color:#5bc0de !important;border-color:#5bc0de !important;}.btn-warning-outline{color:#f0ad4e !important;background-color:transparent;background-image:none;border-color:#f0ad4e !important;}.btn-warning-outline:focus,.btn-warning-outline.focus,.btn-warning-outline:active,.btn-warning-outline.active,.btn-warning-outline:hover,.open>.btn-warning-outline.dropdown-toggle{color:#fff !important;background-color:#f0ad4e !important;border-color:#f0ad4e !important;}.btn-success-outline{color:#5cb85c !important;background-color:transparent;background-image:none;border-color:#5cb85c !important;}.btn-success-outline:focus,.btn-success-outline.focus,.btn-success-outline:active,.btn-success-outline.active,.btn-success-outline:hover,.open>.btn-success-outline.dropdown-toggle{color:#fff !important;background-color:#5cb85c !important;border-color:#5cb85c !important;}#movieList .mix{display:none;}#tvList .mix{display:none;} \ No newline at end of file +@media(min-width:768px){.row{position:relative;}.bottom-align-text{position:absolute;bottom:0;right:0;}}@media(max-width:48em){.home{padding-top:1rem;}}@media(min-width:48em){.home{padding-top:4rem;}}.btn{border-radius:.25rem !important;}.multiSelect{background-color:#4e5d6c;}.form-control-custom{background-color:#4e5d6c !important;color:#fff !important;border-radius:0;box-shadow:0 0 0 !important;}h1{font-size:3.5rem !important;font-weight:600 !important;}.request-title{margin-top:0 !important;font-size:1.9rem !important;}p{font-size:1.1rem !important;}label{display:inline-block !important;margin-bottom:.5rem !important;font-size:16px !important;}.nav-tabs>li.active>a,.nav-tabs>li.active>a:hover,.nav-tabs>li.active>a:focus{background:#4e5d6c;}.navbar .nav a .fa,.dropdown-menu a .fa{font-size:130%;top:1px;position:relative;display:inline-block;margin-right:5px;}.dropdown-menu a .fa{top:2px;}.btn-danger-outline{color:#d9534f !important;background-color:transparent;background-image:none;border-color:#d9534f !important;}.btn-danger-outline:focus,.btn-danger-outline.focus,.btn-danger-outline:active,.btn-danger-outline.active,.btn-danger-outline:hover,.open>.btn-danger-outline.dropdown-toggle{color:#fff !important;background-color:#d9534f !important;border-color:#d9534f !important;}.btn-primary-outline{color:#ff761b !important;background-color:transparent;background-image:none;border-color:#ff761b !important;}.btn-primary-outline:focus,.btn-primary-outline.focus,.btn-primary-outline:active,.btn-primary-outline.active,.btn-primary-outline:hover,.open>.btn-primary-outline.dropdown-toggle{color:#fff !important;background-color:#df691a !important;border-color:#df691a !important;}.btn-info-outline{color:#5bc0de !important;background-color:transparent;background-image:none;border-color:#5bc0de !important;}.btn-info-outline:focus,.btn-info-outline.focus,.btn-info-outline:active,.btn-info-outline.active,.btn-info-outline:hover,.open>.btn-info-outline.dropdown-toggle{color:#fff !important;background-color:#5bc0de !important;border-color:#5bc0de !important;}.btn-warning-outline{color:#f0ad4e !important;background-color:transparent;background-image:none;border-color:#f0ad4e !important;}.btn-warning-outline:focus,.btn-warning-outline.focus,.btn-warning-outline:active,.btn-warning-outline.active,.btn-warning-outline:hover,.open>.btn-warning-outline.dropdown-toggle{color:#fff !important;background-color:#f0ad4e !important;border-color:#f0ad4e !important;}.btn-success-outline{color:#5cb85c !important;background-color:transparent;background-image:none;border-color:#5cb85c !important;}.btn-success-outline:focus,.btn-success-outline.focus,.btn-success-outline:active,.btn-success-outline.active,.btn-success-outline:hover,.open>.btn-success-outline.dropdown-toggle{color:#fff !important;background-color:#5cb85c !important;border-color:#5cb85c !important;}#movieList .mix{display:none;}#tvList .mix{display:none;}.scroll-top-wrapper{position:fixed;opacity:0;visibility:hidden;overflow:hidden;text-align:center;z-index:99999999;background-color:#4e5d6c;color:#eee;width:50px;height:48px;line-height:48px;right:30px;bottom:30px;padding-top:2px;border-top-left-radius:10px;border-top-right-radius:10px;border-bottom-right-radius:10px;border-bottom-left-radius:10px;-webkit-transition:all .5s ease-in-out;-moz-transition:all .5s ease-in-out;-ms-transition:all .5s ease-in-out;-o-transition:all .5s ease-in-out;transition:all .5s ease-in-out;}.scroll-top-wrapper:hover{background-color:#637689;}.scroll-top-wrapper.show{visibility:visible;cursor:pointer;opacity:1;}.scroll-top-wrapper i.fa{line-height:inherit;}.no-search-results{text-align:center;}.no-search-results .no-search-results-icon{font-size:10em;color:#4e5d6c;}.no-search-results .no-search-results-text{margin:20px 0;color:#ccc;}.form-control-search{padding:25px 105px 25px 16px;}.form-control-withbuttons{padding-right:105px;}.input-group-addon .btn-group{position:absolute;right:45px;z-index:3;top:13px;box-shadow:0 0 0;}.input-group-addon .btn-group .btn{border:1px solid rgba(255,255,255,.7) !important;padding:3px 12px;color:rgba(255,255,255,.7) !important;} \ No newline at end of file diff --git a/PlexRequests.UI/Content/custom.scss b/PlexRequests.UI/Content/custom.scss index 11c81ec37..4c42a8dfc 100644 --- a/PlexRequests.UI/Content/custom.scss +++ b/PlexRequests.UI/Content/custom.scss @@ -1,4 +1,5 @@ $form-color: #4e5d6c; +$form-color-lighter: #637689; $primary-colour: #df691a; $primary-colour-outline: #ff761b; $info-colour: #5bc0de; @@ -44,6 +45,8 @@ $i: .form-control-custom { background-color: $form-color $i; color: white $i; + border-radius: 0; + box-shadow: 0 0 0 !important; } @@ -67,6 +70,25 @@ label { font-size: 16px $i; } +.nav-tabs > li.active > a, +.nav-tabs > li.active > a:hover, +.nav-tabs > li.active > a:focus { + background: #4e5d6c; +} + +.navbar .nav a .fa, +.dropdown-menu a .fa { + font-size: 130%; + top: 1px; + position: relative; + display: inline-block; + margin-right: 5px; +} + +.dropdown-menu a .fa { + top: 2px; +} + .btn-danger-outline { color: $danger-colour $i; background-color: transparent; @@ -158,9 +180,89 @@ label { border-color: $success-colour $i; } -#movieList .mix{ - display: none; +#movieList .mix { + display: none; +} + +#tvList .mix { + display: none; +} + +$border-radius: 10px; + +.scroll-top-wrapper { + position: fixed; + opacity: 0; + visibility: hidden; + overflow: hidden; + text-align: center; + z-index: 99999999; + background-color: $form-color; + color: #eeeeee; + width: 50px; + height: 48px; + line-height: 48px; + right: 30px; + bottom: 30px; + padding-top: 2px; + border-top-left-radius: $border-radius; + border-top-right-radius: $border-radius; + border-bottom-right-radius: $border-radius; + border-bottom-left-radius: $border-radius; + -webkit-transition: all 0.5s ease-in-out; + -moz-transition: all 0.5s ease-in-out; + -ms-transition: all 0.5s ease-in-out; + -o-transition: all 0.5s ease-in-out; + transition: all 0.5s ease-in-out; +} + +.scroll-top-wrapper:hover { + background-color: $form-color-lighter; +} + +.scroll-top-wrapper.show { + visibility: visible; + cursor: pointer; + opacity: 1.0; +} + +.scroll-top-wrapper i.fa { + line-height: inherit; +} + + +.no-search-results { + text-align: center; +} + +.no-search-results .no-search-results-icon { + font-size: 10em; + color: $form-color; +} + +.no-search-results .no-search-results-text { + margin: 20px 0; + color: #ccc; +} + +.form-control-search { + padding: 25px 105px 25px 16px; +} + +.form-control-withbuttons { + padding-right: 105px; +} + +.input-group-addon .btn-group { + position: absolute; + right: 45px; + z-index: 3; + top: 13px; + box-shadow: 0 0 0; +} + +.input-group-addon .btn-group .btn { + border: 1px solid rgba(255,255,255,.7) !important; + padding: 3px 12px; + color: rgba(255,255,255,.7) !important; } -#tvList .mix{ - display: none; -} \ No newline at end of file diff --git a/PlexRequests.UI/Content/dataTables.bootstrap.css b/PlexRequests.UI/Content/dataTables.bootstrap.css new file mode 100644 index 000000000..9abe1b5b4 --- /dev/null +++ b/PlexRequests.UI/Content/dataTables.bootstrap.css @@ -0,0 +1,187 @@ +table.dataTable { + clear: both; + margin-top: 6px !important; + margin-bottom: 6px !important; + max-width: none !important; +} +table.dataTable td, +table.dataTable th { + -webkit-box-sizing: content-box; + -moz-box-sizing: content-box; + box-sizing: content-box; +} +table.dataTable td.dataTables_empty, +table.dataTable th.dataTables_empty { + text-align: center; +} +table.dataTable.nowrap th, +table.dataTable.nowrap td { + white-space: nowrap; +} + +div.dataTables_wrapper div.dataTables_length label { + font-weight: normal; + text-align: left; + white-space: nowrap; +} +div.dataTables_wrapper div.dataTables_length select { + width: 75px; + display: inline-block; +} +div.dataTables_wrapper div.dataTables_filter { + text-align: right; +} +div.dataTables_wrapper div.dataTables_filter label { + font-weight: normal; + white-space: nowrap; + text-align: left; +} +div.dataTables_wrapper div.dataTables_filter input { + margin-left: 0.5em; + display: inline-block; + width: auto; +} +div.dataTables_wrapper div.dataTables_info { + padding-top: 8px; + white-space: nowrap; +} +div.dataTables_wrapper div.dataTables_paginate { + margin: 0; + white-space: nowrap; + text-align: right; +} +div.dataTables_wrapper div.dataTables_paginate ul.pagination { + margin: 2px 0; + white-space: nowrap; +} +div.dataTables_wrapper div.dataTables_processing { + position: absolute; + top: 50%; + left: 50%; + width: 200px; + margin-left: -100px; + margin-top: -26px; + text-align: center; + padding: 1em 0; +} + +table.dataTable thead > tr > th.sorting_asc, table.dataTable thead > tr > th.sorting_desc, table.dataTable thead > tr > th.sorting, +table.dataTable thead > tr > td.sorting_asc, +table.dataTable thead > tr > td.sorting_desc, +table.dataTable thead > tr > td.sorting { + padding-right: 30px; +} +table.dataTable thead > tr > th:active, +table.dataTable thead > tr > td:active { + outline: none; +} +table.dataTable thead .sorting, +table.dataTable thead .sorting_asc, +table.dataTable thead .sorting_desc, +table.dataTable thead .sorting_asc_disabled, +table.dataTable thead .sorting_desc_disabled { + cursor: pointer; + position: relative; +} +table.dataTable thead .sorting:after, +table.dataTable thead .sorting_asc:after, +table.dataTable thead .sorting_desc:after, +table.dataTable thead .sorting_asc_disabled:after, +table.dataTable thead .sorting_desc_disabled:after { + position: absolute; + bottom: 8px; + right: 8px; + display: block; + font-family: 'Glyphicons Halflings'; + opacity: 0.5; +} +table.dataTable thead .sorting:after { + opacity: 0.2; + content: "\e150"; + /* sort */ +} +table.dataTable thead .sorting_asc:after { + content: "\e155"; + /* sort-by-attributes */ +} +table.dataTable thead .sorting_desc:after { + content: "\e156"; + /* sort-by-attributes-alt */ +} +table.dataTable thead .sorting_asc_disabled:after, +table.dataTable thead .sorting_desc_disabled:after { + color: #eee; +} + +div.dataTables_scrollHead table.dataTable { + margin-bottom: 0 !important; +} + +div.dataTables_scrollBody table { + border-top: none; + margin-top: 0 !important; + margin-bottom: 0 !important; +} +div.dataTables_scrollBody table thead .sorting:after, +div.dataTables_scrollBody table thead .sorting_asc:after, +div.dataTables_scrollBody table thead .sorting_desc:after { + display: none; +} +div.dataTables_scrollBody table tbody tr:first-child th, +div.dataTables_scrollBody table tbody tr:first-child td { + border-top: none; +} + +div.dataTables_scrollFoot table { + margin-top: 0 !important; + border-top: none; +} + +@media screen and (max-width: 767px) { + div.dataTables_wrapper div.dataTables_length, + div.dataTables_wrapper div.dataTables_filter, + div.dataTables_wrapper div.dataTables_info, + div.dataTables_wrapper div.dataTables_paginate { + text-align: center; + } +} +table.dataTable.table-condensed > thead > tr > th { + padding-right: 20px; +} +table.dataTable.table-condensed .sorting:after, +table.dataTable.table-condensed .sorting_asc:after, +table.dataTable.table-condensed .sorting_desc:after { + top: 6px; + right: 6px; +} + +table.table-bordered.dataTable { + border-collapse: separate !important; +} +table.table-bordered.dataTable th, +table.table-bordered.dataTable td { + border-left-width: 0; +} +table.table-bordered.dataTable th:last-child, table.table-bordered.dataTable th:last-child, +table.table-bordered.dataTable td:last-child, +table.table-bordered.dataTable td:last-child { + border-right-width: 0; +} +table.table-bordered.dataTable tbody th, +table.table-bordered.dataTable tbody td { + border-bottom-width: 0; +} + +div.dataTables_scrollHead table.table-bordered { + border-bottom-width: 0; +} + +div.table-responsive > div.dataTables_wrapper > div.row { + margin: 0; +} +div.table-responsive > div.dataTables_wrapper > div.row > div[class^="col-"]:first-child { + padding-left: 0; +} +div.table-responsive > div.dataTables_wrapper > div.row > div[class^="col-"]:last-child { + padding-right: 0; +} diff --git a/PlexRequests.UI/Content/datatables.js b/PlexRequests.UI/Content/datatables.js new file mode 100644 index 000000000..e4f23def4 --- /dev/null +++ b/PlexRequests.UI/Content/datatables.js @@ -0,0 +1,19026 @@ +/* + * This combined file was created by the DataTables downloader builder: + * https://datatables.net/download + * + * To rebuild or modify this file with the latest versions of the included + * software please visit: + * https://datatables.net/download/#bs-3.3.6/dt-1.10.11,r-2.0.2 + * + * Included libraries: + * Bootstrap 3.3.6, DataTables 1.10.11, Responsive 2.0.2 + */ + +/*! + * Bootstrap v3.3.6 (http://getbootstrap.com) + * Copyright 2011-2015 Twitter, Inc. + * Licensed under the MIT license + */ + +if (typeof jQuery === 'undefined') { + throw new Error('Bootstrap\'s JavaScript requires jQuery') +} + ++function ($) { + 'use strict'; + var version = $.fn.jquery.split(' ')[0].split('.') + if ((version[0] < 2 && version[1] < 9) || (version[0] == 1 && version[1] == 9 && version[2] < 1) || (version[0] > 2)) { + throw new Error('Bootstrap\'s JavaScript requires jQuery version 1.9.1 or higher, but lower than version 3') + } +}(jQuery); + +/* ======================================================================== + * Bootstrap: transition.js v3.3.6 + * http://getbootstrap.com/javascript/#transitions + * ======================================================================== + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // CSS TRANSITION SUPPORT (Shoutout: http://www.modernizr.com/) + // ============================================================ + + function transitionEnd() { + var el = document.createElement('bootstrap') + + var transEndEventNames = { + WebkitTransition : 'webkitTransitionEnd', + MozTransition : 'transitionend', + OTransition : 'oTransitionEnd otransitionend', + transition : 'transitionend' + } + + for (var name in transEndEventNames) { + if (el.style[name] !== undefined) { + return { end: transEndEventNames[name] } + } + } + + return false // explicit for ie8 ( ._.) + } + + // http://blog.alexmaccaw.com/css-transitions + $.fn.emulateTransitionEnd = function (duration) { + var called = false + var $el = this + $(this).one('bsTransitionEnd', function () { called = true }) + var callback = function () { if (!called) $($el).trigger($.support.transition.end) } + setTimeout(callback, duration) + return this + } + + $(function () { + $.support.transition = transitionEnd() + + if (!$.support.transition) return + + $.event.special.bsTransitionEnd = { + bindType: $.support.transition.end, + delegateType: $.support.transition.end, + handle: function (e) { + if ($(e.target).is(this)) return e.handleObj.handler.apply(this, arguments) + } + } + }) + +}(jQuery); + +/* ======================================================================== + * Bootstrap: alert.js v3.3.6 + * http://getbootstrap.com/javascript/#alerts + * ======================================================================== + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // ALERT CLASS DEFINITION + // ====================== + + var dismiss = '[data-dismiss="alert"]' + var Alert = function (el) { + $(el).on('click', dismiss, this.close) + } + + Alert.VERSION = '3.3.6' + + Alert.TRANSITION_DURATION = 150 + + Alert.prototype.close = function (e) { + var $this = $(this) + var selector = $this.attr('data-target') + + if (!selector) { + selector = $this.attr('href') + selector = selector && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7 + } + + var $parent = $(selector) + + if (e) e.preventDefault() + + if (!$parent.length) { + $parent = $this.closest('.alert') + } + + $parent.trigger(e = $.Event('close.bs.alert')) + + if (e.isDefaultPrevented()) return + + $parent.removeClass('in') + + function removeElement() { + // detach from parent, fire event then clean up data + $parent.detach().trigger('closed.bs.alert').remove() + } + + $.support.transition && $parent.hasClass('fade') ? + $parent + .one('bsTransitionEnd', removeElement) + .emulateTransitionEnd(Alert.TRANSITION_DURATION) : + removeElement() + } + + + // ALERT PLUGIN DEFINITION + // ======================= + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.alert') + + if (!data) $this.data('bs.alert', (data = new Alert(this))) + if (typeof option == 'string') data[option].call($this) + }) + } + + var old = $.fn.alert + + $.fn.alert = Plugin + $.fn.alert.Constructor = Alert + + + // ALERT NO CONFLICT + // ================= + + $.fn.alert.noConflict = function () { + $.fn.alert = old + return this + } + + + // ALERT DATA-API + // ============== + + $(document).on('click.bs.alert.data-api', dismiss, Alert.prototype.close) + +}(jQuery); + +/* ======================================================================== + * Bootstrap: button.js v3.3.6 + * http://getbootstrap.com/javascript/#buttons + * ======================================================================== + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // BUTTON PUBLIC CLASS DEFINITION + // ============================== + + var Button = function (element, options) { + this.$element = $(element) + this.options = $.extend({}, Button.DEFAULTS, options) + this.isLoading = false + } + + Button.VERSION = '3.3.6' + + Button.DEFAULTS = { + loadingText: 'loading...' + } + + Button.prototype.setState = function (state) { + var d = 'disabled' + var $el = this.$element + var val = $el.is('input') ? 'val' : 'html' + var data = $el.data() + + state += 'Text' + + if (data.resetText == null) $el.data('resetText', $el[val]()) + + // push to event loop to allow forms to submit + setTimeout($.proxy(function () { + $el[val](data[state] == null ? this.options[state] : data[state]) + + if (state == 'loadingText') { + this.isLoading = true + $el.addClass(d).attr(d, d) + } else if (this.isLoading) { + this.isLoading = false + $el.removeClass(d).removeAttr(d) + } + }, this), 0) + } + + Button.prototype.toggle = function () { + var changed = true + var $parent = this.$element.closest('[data-toggle="buttons"]') + + if ($parent.length) { + var $input = this.$element.find('input') + if ($input.prop('type') == 'radio') { + if ($input.prop('checked')) changed = false + $parent.find('.active').removeClass('active') + this.$element.addClass('active') + } else if ($input.prop('type') == 'checkbox') { + if (($input.prop('checked')) !== this.$element.hasClass('active')) changed = false + this.$element.toggleClass('active') + } + $input.prop('checked', this.$element.hasClass('active')) + if (changed) $input.trigger('change') + } else { + this.$element.attr('aria-pressed', !this.$element.hasClass('active')) + this.$element.toggleClass('active') + } + } + + + // BUTTON PLUGIN DEFINITION + // ======================== + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.button') + var options = typeof option == 'object' && option + + if (!data) $this.data('bs.button', (data = new Button(this, options))) + + if (option == 'toggle') data.toggle() + else if (option) data.setState(option) + }) + } + + var old = $.fn.button + + $.fn.button = Plugin + $.fn.button.Constructor = Button + + + // BUTTON NO CONFLICT + // ================== + + $.fn.button.noConflict = function () { + $.fn.button = old + return this + } + + + // BUTTON DATA-API + // =============== + + $(document) + .on('click.bs.button.data-api', '[data-toggle^="button"]', function (e) { + var $btn = $(e.target) + if (!$btn.hasClass('btn')) $btn = $btn.closest('.btn') + Plugin.call($btn, 'toggle') + if (!($(e.target).is('input[type="radio"]') || $(e.target).is('input[type="checkbox"]'))) e.preventDefault() + }) + .on('focus.bs.button.data-api blur.bs.button.data-api', '[data-toggle^="button"]', function (e) { + $(e.target).closest('.btn').toggleClass('focus', /^focus(in)?$/.test(e.type)) + }) + +}(jQuery); + +/* ======================================================================== + * Bootstrap: carousel.js v3.3.6 + * http://getbootstrap.com/javascript/#carousel + * ======================================================================== + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // CAROUSEL CLASS DEFINITION + // ========================= + + var Carousel = function (element, options) { + this.$element = $(element) + this.$indicators = this.$element.find('.carousel-indicators') + this.options = options + this.paused = null + this.sliding = null + this.interval = null + this.$active = null + this.$items = null + + this.options.keyboard && this.$element.on('keydown.bs.carousel', $.proxy(this.keydown, this)) + + this.options.pause == 'hover' && !('ontouchstart' in document.documentElement) && this.$element + .on('mouseenter.bs.carousel', $.proxy(this.pause, this)) + .on('mouseleave.bs.carousel', $.proxy(this.cycle, this)) + } + + Carousel.VERSION = '3.3.6' + + Carousel.TRANSITION_DURATION = 600 + + Carousel.DEFAULTS = { + interval: 5000, + pause: 'hover', + wrap: true, + keyboard: true + } + + Carousel.prototype.keydown = function (e) { + if (/input|textarea/i.test(e.target.tagName)) return + switch (e.which) { + case 37: this.prev(); break + case 39: this.next(); break + default: return + } + + e.preventDefault() + } + + Carousel.prototype.cycle = function (e) { + e || (this.paused = false) + + this.interval && clearInterval(this.interval) + + this.options.interval + && !this.paused + && (this.interval = setInterval($.proxy(this.next, this), this.options.interval)) + + return this + } + + Carousel.prototype.getItemIndex = function (item) { + this.$items = item.parent().children('.item') + return this.$items.index(item || this.$active) + } + + Carousel.prototype.getItemForDirection = function (direction, active) { + var activeIndex = this.getItemIndex(active) + var willWrap = (direction == 'prev' && activeIndex === 0) + || (direction == 'next' && activeIndex == (this.$items.length - 1)) + if (willWrap && !this.options.wrap) return active + var delta = direction == 'prev' ? -1 : 1 + var itemIndex = (activeIndex + delta) % this.$items.length + return this.$items.eq(itemIndex) + } + + Carousel.prototype.to = function (pos) { + var that = this + var activeIndex = this.getItemIndex(this.$active = this.$element.find('.item.active')) + + if (pos > (this.$items.length - 1) || pos < 0) return + + if (this.sliding) return this.$element.one('slid.bs.carousel', function () { that.to(pos) }) // yes, "slid" + if (activeIndex == pos) return this.pause().cycle() + + return this.slide(pos > activeIndex ? 'next' : 'prev', this.$items.eq(pos)) + } + + Carousel.prototype.pause = function (e) { + e || (this.paused = true) + + if (this.$element.find('.next, .prev').length && $.support.transition) { + this.$element.trigger($.support.transition.end) + this.cycle(true) + } + + this.interval = clearInterval(this.interval) + + return this + } + + Carousel.prototype.next = function () { + if (this.sliding) return + return this.slide('next') + } + + Carousel.prototype.prev = function () { + if (this.sliding) return + return this.slide('prev') + } + + Carousel.prototype.slide = function (type, next) { + var $active = this.$element.find('.item.active') + var $next = next || this.getItemForDirection(type, $active) + var isCycling = this.interval + var direction = type == 'next' ? 'left' : 'right' + var that = this + + if ($next.hasClass('active')) return (this.sliding = false) + + var relatedTarget = $next[0] + var slideEvent = $.Event('slide.bs.carousel', { + relatedTarget: relatedTarget, + direction: direction + }) + this.$element.trigger(slideEvent) + if (slideEvent.isDefaultPrevented()) return + + this.sliding = true + + isCycling && this.pause() + + if (this.$indicators.length) { + this.$indicators.find('.active').removeClass('active') + var $nextIndicator = $(this.$indicators.children()[this.getItemIndex($next)]) + $nextIndicator && $nextIndicator.addClass('active') + } + + var slidEvent = $.Event('slid.bs.carousel', { relatedTarget: relatedTarget, direction: direction }) // yes, "slid" + if ($.support.transition && this.$element.hasClass('slide')) { + $next.addClass(type) + $next[0].offsetWidth // force reflow + $active.addClass(direction) + $next.addClass(direction) + $active + .one('bsTransitionEnd', function () { + $next.removeClass([type, direction].join(' ')).addClass('active') + $active.removeClass(['active', direction].join(' ')) + that.sliding = false + setTimeout(function () { + that.$element.trigger(slidEvent) + }, 0) + }) + .emulateTransitionEnd(Carousel.TRANSITION_DURATION) + } else { + $active.removeClass('active') + $next.addClass('active') + this.sliding = false + this.$element.trigger(slidEvent) + } + + isCycling && this.cycle() + + return this + } + + + // CAROUSEL PLUGIN DEFINITION + // ========================== + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.carousel') + var options = $.extend({}, Carousel.DEFAULTS, $this.data(), typeof option == 'object' && option) + var action = typeof option == 'string' ? option : options.slide + + if (!data) $this.data('bs.carousel', (data = new Carousel(this, options))) + if (typeof option == 'number') data.to(option) + else if (action) data[action]() + else if (options.interval) data.pause().cycle() + }) + } + + var old = $.fn.carousel + + $.fn.carousel = Plugin + $.fn.carousel.Constructor = Carousel + + + // CAROUSEL NO CONFLICT + // ==================== + + $.fn.carousel.noConflict = function () { + $.fn.carousel = old + return this + } + + + // CAROUSEL DATA-API + // ================= + + var clickHandler = function (e) { + var href + var $this = $(this) + var $target = $($this.attr('data-target') || (href = $this.attr('href')) && href.replace(/.*(?=#[^\s]+$)/, '')) // strip for ie7 + if (!$target.hasClass('carousel')) return + var options = $.extend({}, $target.data(), $this.data()) + var slideIndex = $this.attr('data-slide-to') + if (slideIndex) options.interval = false + + Plugin.call($target, options) + + if (slideIndex) { + $target.data('bs.carousel').to(slideIndex) + } + + e.preventDefault() + } + + $(document) + .on('click.bs.carousel.data-api', '[data-slide]', clickHandler) + .on('click.bs.carousel.data-api', '[data-slide-to]', clickHandler) + + $(window).on('load', function () { + $('[data-ride="carousel"]').each(function () { + var $carousel = $(this) + Plugin.call($carousel, $carousel.data()) + }) + }) + +}(jQuery); + +/* ======================================================================== + * Bootstrap: collapse.js v3.3.6 + * http://getbootstrap.com/javascript/#collapse + * ======================================================================== + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // COLLAPSE PUBLIC CLASS DEFINITION + // ================================ + + var Collapse = function (element, options) { + this.$element = $(element) + this.options = $.extend({}, Collapse.DEFAULTS, options) + this.$trigger = $('[data-toggle="collapse"][href="#' + element.id + '"],' + + '[data-toggle="collapse"][data-target="#' + element.id + '"]') + this.transitioning = null + + if (this.options.parent) { + this.$parent = this.getParent() + } else { + this.addAriaAndCollapsedClass(this.$element, this.$trigger) + } + + if (this.options.toggle) this.toggle() + } + + Collapse.VERSION = '3.3.6' + + Collapse.TRANSITION_DURATION = 350 + + Collapse.DEFAULTS = { + toggle: true + } + + Collapse.prototype.dimension = function () { + var hasWidth = this.$element.hasClass('width') + return hasWidth ? 'width' : 'height' + } + + Collapse.prototype.show = function () { + if (this.transitioning || this.$element.hasClass('in')) return + + var activesData + var actives = this.$parent && this.$parent.children('.panel').children('.in, .collapsing') + + if (actives && actives.length) { + activesData = actives.data('bs.collapse') + if (activesData && activesData.transitioning) return + } + + var startEvent = $.Event('show.bs.collapse') + this.$element.trigger(startEvent) + if (startEvent.isDefaultPrevented()) return + + if (actives && actives.length) { + Plugin.call(actives, 'hide') + activesData || actives.data('bs.collapse', null) + } + + var dimension = this.dimension() + + this.$element + .removeClass('collapse') + .addClass('collapsing')[dimension](0) + .attr('aria-expanded', true) + + this.$trigger + .removeClass('collapsed') + .attr('aria-expanded', true) + + this.transitioning = 1 + + var complete = function () { + this.$element + .removeClass('collapsing') + .addClass('collapse in')[dimension]('') + this.transitioning = 0 + this.$element + .trigger('shown.bs.collapse') + } + + if (!$.support.transition) return complete.call(this) + + var scrollSize = $.camelCase(['scroll', dimension].join('-')) + + this.$element + .one('bsTransitionEnd', $.proxy(complete, this)) + .emulateTransitionEnd(Collapse.TRANSITION_DURATION)[dimension](this.$element[0][scrollSize]) + } + + Collapse.prototype.hide = function () { + if (this.transitioning || !this.$element.hasClass('in')) return + + var startEvent = $.Event('hide.bs.collapse') + this.$element.trigger(startEvent) + if (startEvent.isDefaultPrevented()) return + + var dimension = this.dimension() + + this.$element[dimension](this.$element[dimension]())[0].offsetHeight + + this.$element + .addClass('collapsing') + .removeClass('collapse in') + .attr('aria-expanded', false) + + this.$trigger + .addClass('collapsed') + .attr('aria-expanded', false) + + this.transitioning = 1 + + var complete = function () { + this.transitioning = 0 + this.$element + .removeClass('collapsing') + .addClass('collapse') + .trigger('hidden.bs.collapse') + } + + if (!$.support.transition) return complete.call(this) + + this.$element + [dimension](0) + .one('bsTransitionEnd', $.proxy(complete, this)) + .emulateTransitionEnd(Collapse.TRANSITION_DURATION) + } + + Collapse.prototype.toggle = function () { + this[this.$element.hasClass('in') ? 'hide' : 'show']() + } + + Collapse.prototype.getParent = function () { + return $(this.options.parent) + .find('[data-toggle="collapse"][data-parent="' + this.options.parent + '"]') + .each($.proxy(function (i, element) { + var $element = $(element) + this.addAriaAndCollapsedClass(getTargetFromTrigger($element), $element) + }, this)) + .end() + } + + Collapse.prototype.addAriaAndCollapsedClass = function ($element, $trigger) { + var isOpen = $element.hasClass('in') + + $element.attr('aria-expanded', isOpen) + $trigger + .toggleClass('collapsed', !isOpen) + .attr('aria-expanded', isOpen) + } + + function getTargetFromTrigger($trigger) { + var href + var target = $trigger.attr('data-target') + || (href = $trigger.attr('href')) && href.replace(/.*(?=#[^\s]+$)/, '') // strip for ie7 + + return $(target) + } + + + // COLLAPSE PLUGIN DEFINITION + // ========================== + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.collapse') + var options = $.extend({}, Collapse.DEFAULTS, $this.data(), typeof option == 'object' && option) + + if (!data && options.toggle && /show|hide/.test(option)) options.toggle = false + if (!data) $this.data('bs.collapse', (data = new Collapse(this, options))) + if (typeof option == 'string') data[option]() + }) + } + + var old = $.fn.collapse + + $.fn.collapse = Plugin + $.fn.collapse.Constructor = Collapse + + + // COLLAPSE NO CONFLICT + // ==================== + + $.fn.collapse.noConflict = function () { + $.fn.collapse = old + return this + } + + + // COLLAPSE DATA-API + // ================= + + $(document).on('click.bs.collapse.data-api', '[data-toggle="collapse"]', function (e) { + var $this = $(this) + + if (!$this.attr('data-target')) e.preventDefault() + + var $target = getTargetFromTrigger($this) + var data = $target.data('bs.collapse') + var option = data ? 'toggle' : $this.data() + + Plugin.call($target, option) + }) + +}(jQuery); + +/* ======================================================================== + * Bootstrap: dropdown.js v3.3.6 + * http://getbootstrap.com/javascript/#dropdowns + * ======================================================================== + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // DROPDOWN CLASS DEFINITION + // ========================= + + var backdrop = '.dropdown-backdrop' + var toggle = '[data-toggle="dropdown"]' + var Dropdown = function (element) { + $(element).on('click.bs.dropdown', this.toggle) + } + + Dropdown.VERSION = '3.3.6' + + function getParent($this) { + var selector = $this.attr('data-target') + + if (!selector) { + selector = $this.attr('href') + selector = selector && /#[A-Za-z]/.test(selector) && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7 + } + + var $parent = selector && $(selector) + + return $parent && $parent.length ? $parent : $this.parent() + } + + function clearMenus(e) { + if (e && e.which === 3) return + $(backdrop).remove() + $(toggle).each(function () { + var $this = $(this) + var $parent = getParent($this) + var relatedTarget = { relatedTarget: this } + + if (!$parent.hasClass('open')) return + + if (e && e.type == 'click' && /input|textarea/i.test(e.target.tagName) && $.contains($parent[0], e.target)) return + + $parent.trigger(e = $.Event('hide.bs.dropdown', relatedTarget)) + + if (e.isDefaultPrevented()) return + + $this.attr('aria-expanded', 'false') + $parent.removeClass('open').trigger($.Event('hidden.bs.dropdown', relatedTarget)) + }) + } + + Dropdown.prototype.toggle = function (e) { + var $this = $(this) + + if ($this.is('.disabled, :disabled')) return + + var $parent = getParent($this) + var isActive = $parent.hasClass('open') + + clearMenus() + + if (!isActive) { + if ('ontouchstart' in document.documentElement && !$parent.closest('.navbar-nav').length) { + // if mobile we use a backdrop because click events don't delegate + $(document.createElement('div')) + .addClass('dropdown-backdrop') + .insertAfter($(this)) + .on('click', clearMenus) + } + + var relatedTarget = { relatedTarget: this } + $parent.trigger(e = $.Event('show.bs.dropdown', relatedTarget)) + + if (e.isDefaultPrevented()) return + + $this + .trigger('focus') + .attr('aria-expanded', 'true') + + $parent + .toggleClass('open') + .trigger($.Event('shown.bs.dropdown', relatedTarget)) + } + + return false + } + + Dropdown.prototype.keydown = function (e) { + if (!/(38|40|27|32)/.test(e.which) || /input|textarea/i.test(e.target.tagName)) return + + var $this = $(this) + + e.preventDefault() + e.stopPropagation() + + if ($this.is('.disabled, :disabled')) return + + var $parent = getParent($this) + var isActive = $parent.hasClass('open') + + if (!isActive && e.which != 27 || isActive && e.which == 27) { + if (e.which == 27) $parent.find(toggle).trigger('focus') + return $this.trigger('click') + } + + var desc = ' li:not(.disabled):visible a' + var $items = $parent.find('.dropdown-menu' + desc) + + if (!$items.length) return + + var index = $items.index(e.target) + + if (e.which == 38 && index > 0) index-- // up + if (e.which == 40 && index < $items.length - 1) index++ // down + if (!~index) index = 0 + + $items.eq(index).trigger('focus') + } + + + // DROPDOWN PLUGIN DEFINITION + // ========================== + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.dropdown') + + if (!data) $this.data('bs.dropdown', (data = new Dropdown(this))) + if (typeof option == 'string') data[option].call($this) + }) + } + + var old = $.fn.dropdown + + $.fn.dropdown = Plugin + $.fn.dropdown.Constructor = Dropdown + + + // DROPDOWN NO CONFLICT + // ==================== + + $.fn.dropdown.noConflict = function () { + $.fn.dropdown = old + return this + } + + + // APPLY TO STANDARD DROPDOWN ELEMENTS + // =================================== + + $(document) + .on('click.bs.dropdown.data-api', clearMenus) + .on('click.bs.dropdown.data-api', '.dropdown form', function (e) { e.stopPropagation() }) + .on('click.bs.dropdown.data-api', toggle, Dropdown.prototype.toggle) + .on('keydown.bs.dropdown.data-api', toggle, Dropdown.prototype.keydown) + .on('keydown.bs.dropdown.data-api', '.dropdown-menu', Dropdown.prototype.keydown) + +}(jQuery); + +/* ======================================================================== + * Bootstrap: modal.js v3.3.6 + * http://getbootstrap.com/javascript/#modals + * ======================================================================== + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // MODAL CLASS DEFINITION + // ====================== + + var Modal = function (element, options) { + this.options = options + this.$body = $(document.body) + this.$element = $(element) + this.$dialog = this.$element.find('.modal-dialog') + this.$backdrop = null + this.isShown = null + this.originalBodyPad = null + this.scrollbarWidth = 0 + this.ignoreBackdropClick = false + + if (this.options.remote) { + this.$element + .find('.modal-content') + .load(this.options.remote, $.proxy(function () { + this.$element.trigger('loaded.bs.modal') + }, this)) + } + } + + Modal.VERSION = '3.3.6' + + Modal.TRANSITION_DURATION = 300 + Modal.BACKDROP_TRANSITION_DURATION = 150 + + Modal.DEFAULTS = { + backdrop: true, + keyboard: true, + show: true + } + + Modal.prototype.toggle = function (_relatedTarget) { + return this.isShown ? this.hide() : this.show(_relatedTarget) + } + + Modal.prototype.show = function (_relatedTarget) { + var that = this + var e = $.Event('show.bs.modal', { relatedTarget: _relatedTarget }) + + this.$element.trigger(e) + + if (this.isShown || e.isDefaultPrevented()) return + + this.isShown = true + + this.checkScrollbar() + this.setScrollbar() + this.$body.addClass('modal-open') + + this.escape() + this.resize() + + this.$element.on('click.dismiss.bs.modal', '[data-dismiss="modal"]', $.proxy(this.hide, this)) + + this.$dialog.on('mousedown.dismiss.bs.modal', function () { + that.$element.one('mouseup.dismiss.bs.modal', function (e) { + if ($(e.target).is(that.$element)) that.ignoreBackdropClick = true + }) + }) + + this.backdrop(function () { + var transition = $.support.transition && that.$element.hasClass('fade') + + if (!that.$element.parent().length) { + that.$element.appendTo(that.$body) // don't move modals dom position + } + + that.$element + .show() + .scrollTop(0) + + that.adjustDialog() + + if (transition) { + that.$element[0].offsetWidth // force reflow + } + + that.$element.addClass('in') + + that.enforceFocus() + + var e = $.Event('shown.bs.modal', { relatedTarget: _relatedTarget }) + + transition ? + that.$dialog // wait for modal to slide in + .one('bsTransitionEnd', function () { + that.$element.trigger('focus').trigger(e) + }) + .emulateTransitionEnd(Modal.TRANSITION_DURATION) : + that.$element.trigger('focus').trigger(e) + }) + } + + Modal.prototype.hide = function (e) { + if (e) e.preventDefault() + + e = $.Event('hide.bs.modal') + + this.$element.trigger(e) + + if (!this.isShown || e.isDefaultPrevented()) return + + this.isShown = false + + this.escape() + this.resize() + + $(document).off('focusin.bs.modal') + + this.$element + .removeClass('in') + .off('click.dismiss.bs.modal') + .off('mouseup.dismiss.bs.modal') + + this.$dialog.off('mousedown.dismiss.bs.modal') + + $.support.transition && this.$element.hasClass('fade') ? + this.$element + .one('bsTransitionEnd', $.proxy(this.hideModal, this)) + .emulateTransitionEnd(Modal.TRANSITION_DURATION) : + this.hideModal() + } + + Modal.prototype.enforceFocus = function () { + $(document) + .off('focusin.bs.modal') // guard against infinite focus loop + .on('focusin.bs.modal', $.proxy(function (e) { + if (this.$element[0] !== e.target && !this.$element.has(e.target).length) { + this.$element.trigger('focus') + } + }, this)) + } + + Modal.prototype.escape = function () { + if (this.isShown && this.options.keyboard) { + this.$element.on('keydown.dismiss.bs.modal', $.proxy(function (e) { + e.which == 27 && this.hide() + }, this)) + } else if (!this.isShown) { + this.$element.off('keydown.dismiss.bs.modal') + } + } + + Modal.prototype.resize = function () { + if (this.isShown) { + $(window).on('resize.bs.modal', $.proxy(this.handleUpdate, this)) + } else { + $(window).off('resize.bs.modal') + } + } + + Modal.prototype.hideModal = function () { + var that = this + this.$element.hide() + this.backdrop(function () { + that.$body.removeClass('modal-open') + that.resetAdjustments() + that.resetScrollbar() + that.$element.trigger('hidden.bs.modal') + }) + } + + Modal.prototype.removeBackdrop = function () { + this.$backdrop && this.$backdrop.remove() + this.$backdrop = null + } + + Modal.prototype.backdrop = function (callback) { + var that = this + var animate = this.$element.hasClass('fade') ? 'fade' : '' + + if (this.isShown && this.options.backdrop) { + var doAnimate = $.support.transition && animate + + this.$backdrop = $(document.createElement('div')) + .addClass('modal-backdrop ' + animate) + .appendTo(this.$body) + + this.$element.on('click.dismiss.bs.modal', $.proxy(function (e) { + if (this.ignoreBackdropClick) { + this.ignoreBackdropClick = false + return + } + if (e.target !== e.currentTarget) return + this.options.backdrop == 'static' + ? this.$element[0].focus() + : this.hide() + }, this)) + + if (doAnimate) this.$backdrop[0].offsetWidth // force reflow + + this.$backdrop.addClass('in') + + if (!callback) return + + doAnimate ? + this.$backdrop + .one('bsTransitionEnd', callback) + .emulateTransitionEnd(Modal.BACKDROP_TRANSITION_DURATION) : + callback() + + } else if (!this.isShown && this.$backdrop) { + this.$backdrop.removeClass('in') + + var callbackRemove = function () { + that.removeBackdrop() + callback && callback() + } + $.support.transition && this.$element.hasClass('fade') ? + this.$backdrop + .one('bsTransitionEnd', callbackRemove) + .emulateTransitionEnd(Modal.BACKDROP_TRANSITION_DURATION) : + callbackRemove() + + } else if (callback) { + callback() + } + } + + // these following methods are used to handle overflowing modals + + Modal.prototype.handleUpdate = function () { + this.adjustDialog() + } + + Modal.prototype.adjustDialog = function () { + var modalIsOverflowing = this.$element[0].scrollHeight > document.documentElement.clientHeight + + this.$element.css({ + paddingLeft: !this.bodyIsOverflowing && modalIsOverflowing ? this.scrollbarWidth : '', + paddingRight: this.bodyIsOverflowing && !modalIsOverflowing ? this.scrollbarWidth : '' + }) + } + + Modal.prototype.resetAdjustments = function () { + this.$element.css({ + paddingLeft: '', + paddingRight: '' + }) + } + + Modal.prototype.checkScrollbar = function () { + var fullWindowWidth = window.innerWidth + if (!fullWindowWidth) { // workaround for missing window.innerWidth in IE8 + var documentElementRect = document.documentElement.getBoundingClientRect() + fullWindowWidth = documentElementRect.right - Math.abs(documentElementRect.left) + } + this.bodyIsOverflowing = document.body.clientWidth < fullWindowWidth + this.scrollbarWidth = this.measureScrollbar() + } + + Modal.prototype.setScrollbar = function () { + var bodyPad = parseInt((this.$body.css('padding-right') || 0), 10) + this.originalBodyPad = document.body.style.paddingRight || '' + if (this.bodyIsOverflowing) this.$body.css('padding-right', bodyPad + this.scrollbarWidth) + } + + Modal.prototype.resetScrollbar = function () { + this.$body.css('padding-right', this.originalBodyPad) + } + + Modal.prototype.measureScrollbar = function () { // thx walsh + var scrollDiv = document.createElement('div') + scrollDiv.className = 'modal-scrollbar-measure' + this.$body.append(scrollDiv) + var scrollbarWidth = scrollDiv.offsetWidth - scrollDiv.clientWidth + this.$body[0].removeChild(scrollDiv) + return scrollbarWidth + } + + + // MODAL PLUGIN DEFINITION + // ======================= + + function Plugin(option, _relatedTarget) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.modal') + var options = $.extend({}, Modal.DEFAULTS, $this.data(), typeof option == 'object' && option) + + if (!data) $this.data('bs.modal', (data = new Modal(this, options))) + if (typeof option == 'string') data[option](_relatedTarget) + else if (options.show) data.show(_relatedTarget) + }) + } + + var old = $.fn.modal + + $.fn.modal = Plugin + $.fn.modal.Constructor = Modal + + + // MODAL NO CONFLICT + // ================= + + $.fn.modal.noConflict = function () { + $.fn.modal = old + return this + } + + + // MODAL DATA-API + // ============== + + $(document).on('click.bs.modal.data-api', '[data-toggle="modal"]', function (e) { + var $this = $(this) + var href = $this.attr('href') + var $target = $($this.attr('data-target') || (href && href.replace(/.*(?=#[^\s]+$)/, ''))) // strip for ie7 + var option = $target.data('bs.modal') ? 'toggle' : $.extend({ remote: !/#/.test(href) && href }, $target.data(), $this.data()) + + if ($this.is('a')) e.preventDefault() + + $target.one('show.bs.modal', function (showEvent) { + if (showEvent.isDefaultPrevented()) return // only register focus restorer if modal will actually get shown + $target.one('hidden.bs.modal', function () { + $this.is(':visible') && $this.trigger('focus') + }) + }) + Plugin.call($target, option, this) + }) + +}(jQuery); + +/* ======================================================================== + * Bootstrap: tooltip.js v3.3.6 + * http://getbootstrap.com/javascript/#tooltip + * Inspired by the original jQuery.tipsy by Jason Frame + * ======================================================================== + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // TOOLTIP PUBLIC CLASS DEFINITION + // =============================== + + var Tooltip = function (element, options) { + this.type = null + this.options = null + this.enabled = null + this.timeout = null + this.hoverState = null + this.$element = null + this.inState = null + + this.init('tooltip', element, options) + } + + Tooltip.VERSION = '3.3.6' + + Tooltip.TRANSITION_DURATION = 150 + + Tooltip.DEFAULTS = { + animation: true, + placement: 'top', + selector: false, + template: '', + trigger: 'hover focus', + title: '', + delay: 0, + html: false, + container: false, + viewport: { + selector: 'body', + padding: 0 + } + } + + Tooltip.prototype.init = function (type, element, options) { + this.enabled = true + this.type = type + this.$element = $(element) + this.options = this.getOptions(options) + this.$viewport = this.options.viewport && $($.isFunction(this.options.viewport) ? this.options.viewport.call(this, this.$element) : (this.options.viewport.selector || this.options.viewport)) + this.inState = { click: false, hover: false, focus: false } + + if (this.$element[0] instanceof document.constructor && !this.options.selector) { + throw new Error('`selector` option must be specified when initializing ' + this.type + ' on the window.document object!') + } + + var triggers = this.options.trigger.split(' ') + + for (var i = triggers.length; i--;) { + var trigger = triggers[i] + + if (trigger == 'click') { + this.$element.on('click.' + this.type, this.options.selector, $.proxy(this.toggle, this)) + } else if (trigger != 'manual') { + var eventIn = trigger == 'hover' ? 'mouseenter' : 'focusin' + var eventOut = trigger == 'hover' ? 'mouseleave' : 'focusout' + + this.$element.on(eventIn + '.' + this.type, this.options.selector, $.proxy(this.enter, this)) + this.$element.on(eventOut + '.' + this.type, this.options.selector, $.proxy(this.leave, this)) + } + } + + this.options.selector ? + (this._options = $.extend({}, this.options, { trigger: 'manual', selector: '' })) : + this.fixTitle() + } + + Tooltip.prototype.getDefaults = function () { + return Tooltip.DEFAULTS + } + + Tooltip.prototype.getOptions = function (options) { + options = $.extend({}, this.getDefaults(), this.$element.data(), options) + + if (options.delay && typeof options.delay == 'number') { + options.delay = { + show: options.delay, + hide: options.delay + } + } + + return options + } + + Tooltip.prototype.getDelegateOptions = function () { + var options = {} + var defaults = this.getDefaults() + + this._options && $.each(this._options, function (key, value) { + if (defaults[key] != value) options[key] = value + }) + + return options + } + + Tooltip.prototype.enter = function (obj) { + var self = obj instanceof this.constructor ? + obj : $(obj.currentTarget).data('bs.' + this.type) + + if (!self) { + self = new this.constructor(obj.currentTarget, this.getDelegateOptions()) + $(obj.currentTarget).data('bs.' + this.type, self) + } + + if (obj instanceof $.Event) { + self.inState[obj.type == 'focusin' ? 'focus' : 'hover'] = true + } + + if (self.tip().hasClass('in') || self.hoverState == 'in') { + self.hoverState = 'in' + return + } + + clearTimeout(self.timeout) + + self.hoverState = 'in' + + if (!self.options.delay || !self.options.delay.show) return self.show() + + self.timeout = setTimeout(function () { + if (self.hoverState == 'in') self.show() + }, self.options.delay.show) + } + + Tooltip.prototype.isInStateTrue = function () { + for (var key in this.inState) { + if (this.inState[key]) return true + } + + return false + } + + Tooltip.prototype.leave = function (obj) { + var self = obj instanceof this.constructor ? + obj : $(obj.currentTarget).data('bs.' + this.type) + + if (!self) { + self = new this.constructor(obj.currentTarget, this.getDelegateOptions()) + $(obj.currentTarget).data('bs.' + this.type, self) + } + + if (obj instanceof $.Event) { + self.inState[obj.type == 'focusout' ? 'focus' : 'hover'] = false + } + + if (self.isInStateTrue()) return + + clearTimeout(self.timeout) + + self.hoverState = 'out' + + if (!self.options.delay || !self.options.delay.hide) return self.hide() + + self.timeout = setTimeout(function () { + if (self.hoverState == 'out') self.hide() + }, self.options.delay.hide) + } + + Tooltip.prototype.show = function () { + var e = $.Event('show.bs.' + this.type) + + if (this.hasContent() && this.enabled) { + this.$element.trigger(e) + + var inDom = $.contains(this.$element[0].ownerDocument.documentElement, this.$element[0]) + if (e.isDefaultPrevented() || !inDom) return + var that = this + + var $tip = this.tip() + + var tipId = this.getUID(this.type) + + this.setContent() + $tip.attr('id', tipId) + this.$element.attr('aria-describedby', tipId) + + if (this.options.animation) $tip.addClass('fade') + + var placement = typeof this.options.placement == 'function' ? + this.options.placement.call(this, $tip[0], this.$element[0]) : + this.options.placement + + var autoToken = /\s?auto?\s?/i + var autoPlace = autoToken.test(placement) + if (autoPlace) placement = placement.replace(autoToken, '') || 'top' + + $tip + .detach() + .css({ top: 0, left: 0, display: 'block' }) + .addClass(placement) + .data('bs.' + this.type, this) + + this.options.container ? $tip.appendTo(this.options.container) : $tip.insertAfter(this.$element) + this.$element.trigger('inserted.bs.' + this.type) + + var pos = this.getPosition() + var actualWidth = $tip[0].offsetWidth + var actualHeight = $tip[0].offsetHeight + + if (autoPlace) { + var orgPlacement = placement + var viewportDim = this.getPosition(this.$viewport) + + placement = placement == 'bottom' && pos.bottom + actualHeight > viewportDim.bottom ? 'top' : + placement == 'top' && pos.top - actualHeight < viewportDim.top ? 'bottom' : + placement == 'right' && pos.right + actualWidth > viewportDim.width ? 'left' : + placement == 'left' && pos.left - actualWidth < viewportDim.left ? 'right' : + placement + + $tip + .removeClass(orgPlacement) + .addClass(placement) + } + + var calculatedOffset = this.getCalculatedOffset(placement, pos, actualWidth, actualHeight) + + this.applyPlacement(calculatedOffset, placement) + + var complete = function () { + var prevHoverState = that.hoverState + that.$element.trigger('shown.bs.' + that.type) + that.hoverState = null + + if (prevHoverState == 'out') that.leave(that) + } + + $.support.transition && this.$tip.hasClass('fade') ? + $tip + .one('bsTransitionEnd', complete) + .emulateTransitionEnd(Tooltip.TRANSITION_DURATION) : + complete() + } + } + + Tooltip.prototype.applyPlacement = function (offset, placement) { + var $tip = this.tip() + var width = $tip[0].offsetWidth + var height = $tip[0].offsetHeight + + // manually read margins because getBoundingClientRect includes difference + var marginTop = parseInt($tip.css('margin-top'), 10) + var marginLeft = parseInt($tip.css('margin-left'), 10) + + // we must check for NaN for ie 8/9 + if (isNaN(marginTop)) marginTop = 0 + if (isNaN(marginLeft)) marginLeft = 0 + + offset.top += marginTop + offset.left += marginLeft + + // $.fn.offset doesn't round pixel values + // so we use setOffset directly with our own function B-0 + $.offset.setOffset($tip[0], $.extend({ + using: function (props) { + $tip.css({ + top: Math.round(props.top), + left: Math.round(props.left) + }) + } + }, offset), 0) + + $tip.addClass('in') + + // check to see if placing tip in new offset caused the tip to resize itself + var actualWidth = $tip[0].offsetWidth + var actualHeight = $tip[0].offsetHeight + + if (placement == 'top' && actualHeight != height) { + offset.top = offset.top + height - actualHeight + } + + var delta = this.getViewportAdjustedDelta(placement, offset, actualWidth, actualHeight) + + if (delta.left) offset.left += delta.left + else offset.top += delta.top + + var isVertical = /top|bottom/.test(placement) + var arrowDelta = isVertical ? delta.left * 2 - width + actualWidth : delta.top * 2 - height + actualHeight + var arrowOffsetPosition = isVertical ? 'offsetWidth' : 'offsetHeight' + + $tip.offset(offset) + this.replaceArrow(arrowDelta, $tip[0][arrowOffsetPosition], isVertical) + } + + Tooltip.prototype.replaceArrow = function (delta, dimension, isVertical) { + this.arrow() + .css(isVertical ? 'left' : 'top', 50 * (1 - delta / dimension) + '%') + .css(isVertical ? 'top' : 'left', '') + } + + Tooltip.prototype.setContent = function () { + var $tip = this.tip() + var title = this.getTitle() + + $tip.find('.tooltip-inner')[this.options.html ? 'html' : 'text'](title) + $tip.removeClass('fade in top bottom left right') + } + + Tooltip.prototype.hide = function (callback) { + var that = this + var $tip = $(this.$tip) + var e = $.Event('hide.bs.' + this.type) + + function complete() { + if (that.hoverState != 'in') $tip.detach() + that.$element + .removeAttr('aria-describedby') + .trigger('hidden.bs.' + that.type) + callback && callback() + } + + this.$element.trigger(e) + + if (e.isDefaultPrevented()) return + + $tip.removeClass('in') + + $.support.transition && $tip.hasClass('fade') ? + $tip + .one('bsTransitionEnd', complete) + .emulateTransitionEnd(Tooltip.TRANSITION_DURATION) : + complete() + + this.hoverState = null + + return this + } + + Tooltip.prototype.fixTitle = function () { + var $e = this.$element + if ($e.attr('title') || typeof $e.attr('data-original-title') != 'string') { + $e.attr('data-original-title', $e.attr('title') || '').attr('title', '') + } + } + + Tooltip.prototype.hasContent = function () { + return this.getTitle() + } + + Tooltip.prototype.getPosition = function ($element) { + $element = $element || this.$element + + var el = $element[0] + var isBody = el.tagName == 'BODY' + + var elRect = el.getBoundingClientRect() + if (elRect.width == null) { + // width and height are missing in IE8, so compute them manually; see https://github.com/twbs/bootstrap/issues/14093 + elRect = $.extend({}, elRect, { width: elRect.right - elRect.left, height: elRect.bottom - elRect.top }) + } + var elOffset = isBody ? { top: 0, left: 0 } : $element.offset() + var scroll = { scroll: isBody ? document.documentElement.scrollTop || document.body.scrollTop : $element.scrollTop() } + var outerDims = isBody ? { width: $(window).width(), height: $(window).height() } : null + + return $.extend({}, elRect, scroll, outerDims, elOffset) + } + + Tooltip.prototype.getCalculatedOffset = function (placement, pos, actualWidth, actualHeight) { + return placement == 'bottom' ? { top: pos.top + pos.height, left: pos.left + pos.width / 2 - actualWidth / 2 } : + placement == 'top' ? { top: pos.top - actualHeight, left: pos.left + pos.width / 2 - actualWidth / 2 } : + placement == 'left' ? { top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left - actualWidth } : + /* placement == 'right' */ { top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left + pos.width } + + } + + Tooltip.prototype.getViewportAdjustedDelta = function (placement, pos, actualWidth, actualHeight) { + var delta = { top: 0, left: 0 } + if (!this.$viewport) return delta + + var viewportPadding = this.options.viewport && this.options.viewport.padding || 0 + var viewportDimensions = this.getPosition(this.$viewport) + + if (/right|left/.test(placement)) { + var topEdgeOffset = pos.top - viewportPadding - viewportDimensions.scroll + var bottomEdgeOffset = pos.top + viewportPadding - viewportDimensions.scroll + actualHeight + if (topEdgeOffset < viewportDimensions.top) { // top overflow + delta.top = viewportDimensions.top - topEdgeOffset + } else if (bottomEdgeOffset > viewportDimensions.top + viewportDimensions.height) { // bottom overflow + delta.top = viewportDimensions.top + viewportDimensions.height - bottomEdgeOffset + } + } else { + var leftEdgeOffset = pos.left - viewportPadding + var rightEdgeOffset = pos.left + viewportPadding + actualWidth + if (leftEdgeOffset < viewportDimensions.left) { // left overflow + delta.left = viewportDimensions.left - leftEdgeOffset + } else if (rightEdgeOffset > viewportDimensions.right) { // right overflow + delta.left = viewportDimensions.left + viewportDimensions.width - rightEdgeOffset + } + } + + return delta + } + + Tooltip.prototype.getTitle = function () { + var title + var $e = this.$element + var o = this.options + + title = $e.attr('data-original-title') + || (typeof o.title == 'function' ? o.title.call($e[0]) : o.title) + + return title + } + + Tooltip.prototype.getUID = function (prefix) { + do prefix += ~~(Math.random() * 1000000) + while (document.getElementById(prefix)) + return prefix + } + + Tooltip.prototype.tip = function () { + if (!this.$tip) { + this.$tip = $(this.options.template) + if (this.$tip.length != 1) { + throw new Error(this.type + ' `template` option must consist of exactly 1 top-level element!') + } + } + return this.$tip + } + + Tooltip.prototype.arrow = function () { + return (this.$arrow = this.$arrow || this.tip().find('.tooltip-arrow')) + } + + Tooltip.prototype.enable = function () { + this.enabled = true + } + + Tooltip.prototype.disable = function () { + this.enabled = false + } + + Tooltip.prototype.toggleEnabled = function () { + this.enabled = !this.enabled + } + + Tooltip.prototype.toggle = function (e) { + var self = this + if (e) { + self = $(e.currentTarget).data('bs.' + this.type) + if (!self) { + self = new this.constructor(e.currentTarget, this.getDelegateOptions()) + $(e.currentTarget).data('bs.' + this.type, self) + } + } + + if (e) { + self.inState.click = !self.inState.click + if (self.isInStateTrue()) self.enter(self) + else self.leave(self) + } else { + self.tip().hasClass('in') ? self.leave(self) : self.enter(self) + } + } + + Tooltip.prototype.destroy = function () { + var that = this + clearTimeout(this.timeout) + this.hide(function () { + that.$element.off('.' + that.type).removeData('bs.' + that.type) + if (that.$tip) { + that.$tip.detach() + } + that.$tip = null + that.$arrow = null + that.$viewport = null + }) + } + + + // TOOLTIP PLUGIN DEFINITION + // ========================= + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.tooltip') + var options = typeof option == 'object' && option + + if (!data && /destroy|hide/.test(option)) return + if (!data) $this.data('bs.tooltip', (data = new Tooltip(this, options))) + if (typeof option == 'string') data[option]() + }) + } + + var old = $.fn.tooltip + + $.fn.tooltip = Plugin + $.fn.tooltip.Constructor = Tooltip + + + // TOOLTIP NO CONFLICT + // =================== + + $.fn.tooltip.noConflict = function () { + $.fn.tooltip = old + return this + } + +}(jQuery); + +/* ======================================================================== + * Bootstrap: popover.js v3.3.6 + * http://getbootstrap.com/javascript/#popovers + * ======================================================================== + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // POPOVER PUBLIC CLASS DEFINITION + // =============================== + + var Popover = function (element, options) { + this.init('popover', element, options) + } + + if (!$.fn.tooltip) throw new Error('Popover requires tooltip.js') + + Popover.VERSION = '3.3.6' + + Popover.DEFAULTS = $.extend({}, $.fn.tooltip.Constructor.DEFAULTS, { + placement: 'right', + trigger: 'click', + content: '', + template: '' + }) + + + // NOTE: POPOVER EXTENDS tooltip.js + // ================================ + + Popover.prototype = $.extend({}, $.fn.tooltip.Constructor.prototype) + + Popover.prototype.constructor = Popover + + Popover.prototype.getDefaults = function () { + return Popover.DEFAULTS + } + + Popover.prototype.setContent = function () { + var $tip = this.tip() + var title = this.getTitle() + var content = this.getContent() + + $tip.find('.popover-title')[this.options.html ? 'html' : 'text'](title) + $tip.find('.popover-content').children().detach().end()[ // we use append for html objects to maintain js events + this.options.html ? (typeof content == 'string' ? 'html' : 'append') : 'text' + ](content) + + $tip.removeClass('fade top bottom left right in') + + // IE8 doesn't accept hiding via the `:empty` pseudo selector, we have to do + // this manually by checking the contents. + if (!$tip.find('.popover-title').html()) $tip.find('.popover-title').hide() + } + + Popover.prototype.hasContent = function () { + return this.getTitle() || this.getContent() + } + + Popover.prototype.getContent = function () { + var $e = this.$element + var o = this.options + + return $e.attr('data-content') + || (typeof o.content == 'function' ? + o.content.call($e[0]) : + o.content) + } + + Popover.prototype.arrow = function () { + return (this.$arrow = this.$arrow || this.tip().find('.arrow')) + } + + + // POPOVER PLUGIN DEFINITION + // ========================= + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.popover') + var options = typeof option == 'object' && option + + if (!data && /destroy|hide/.test(option)) return + if (!data) $this.data('bs.popover', (data = new Popover(this, options))) + if (typeof option == 'string') data[option]() + }) + } + + var old = $.fn.popover + + $.fn.popover = Plugin + $.fn.popover.Constructor = Popover + + + // POPOVER NO CONFLICT + // =================== + + $.fn.popover.noConflict = function () { + $.fn.popover = old + return this + } + +}(jQuery); + +/* ======================================================================== + * Bootstrap: scrollspy.js v3.3.6 + * http://getbootstrap.com/javascript/#scrollspy + * ======================================================================== + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // SCROLLSPY CLASS DEFINITION + // ========================== + + function ScrollSpy(element, options) { + this.$body = $(document.body) + this.$scrollElement = $(element).is(document.body) ? $(window) : $(element) + this.options = $.extend({}, ScrollSpy.DEFAULTS, options) + this.selector = (this.options.target || '') + ' .nav li > a' + this.offsets = [] + this.targets = [] + this.activeTarget = null + this.scrollHeight = 0 + + this.$scrollElement.on('scroll.bs.scrollspy', $.proxy(this.process, this)) + this.refresh() + this.process() + } + + ScrollSpy.VERSION = '3.3.6' + + ScrollSpy.DEFAULTS = { + offset: 10 + } + + ScrollSpy.prototype.getScrollHeight = function () { + return this.$scrollElement[0].scrollHeight || Math.max(this.$body[0].scrollHeight, document.documentElement.scrollHeight) + } + + ScrollSpy.prototype.refresh = function () { + var that = this + var offsetMethod = 'offset' + var offsetBase = 0 + + this.offsets = [] + this.targets = [] + this.scrollHeight = this.getScrollHeight() + + if (!$.isWindow(this.$scrollElement[0])) { + offsetMethod = 'position' + offsetBase = this.$scrollElement.scrollTop() + } + + this.$body + .find(this.selector) + .map(function () { + var $el = $(this) + var href = $el.data('target') || $el.attr('href') + var $href = /^#./.test(href) && $(href) + + return ($href + && $href.length + && $href.is(':visible') + && [[$href[offsetMethod]().top + offsetBase, href]]) || null + }) + .sort(function (a, b) { return a[0] - b[0] }) + .each(function () { + that.offsets.push(this[0]) + that.targets.push(this[1]) + }) + } + + ScrollSpy.prototype.process = function () { + var scrollTop = this.$scrollElement.scrollTop() + this.options.offset + var scrollHeight = this.getScrollHeight() + var maxScroll = this.options.offset + scrollHeight - this.$scrollElement.height() + var offsets = this.offsets + var targets = this.targets + var activeTarget = this.activeTarget + var i + + if (this.scrollHeight != scrollHeight) { + this.refresh() + } + + if (scrollTop >= maxScroll) { + return activeTarget != (i = targets[targets.length - 1]) && this.activate(i) + } + + if (activeTarget && scrollTop < offsets[0]) { + this.activeTarget = null + return this.clear() + } + + for (i = offsets.length; i--;) { + activeTarget != targets[i] + && scrollTop >= offsets[i] + && (offsets[i + 1] === undefined || scrollTop < offsets[i + 1]) + && this.activate(targets[i]) + } + } + + ScrollSpy.prototype.activate = function (target) { + this.activeTarget = target + + this.clear() + + var selector = this.selector + + '[data-target="' + target + '"],' + + this.selector + '[href="' + target + '"]' + + var active = $(selector) + .parents('li') + .addClass('active') + + if (active.parent('.dropdown-menu').length) { + active = active + .closest('li.dropdown') + .addClass('active') + } + + active.trigger('activate.bs.scrollspy') + } + + ScrollSpy.prototype.clear = function () { + $(this.selector) + .parentsUntil(this.options.target, '.active') + .removeClass('active') + } + + + // SCROLLSPY PLUGIN DEFINITION + // =========================== + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.scrollspy') + var options = typeof option == 'object' && option + + if (!data) $this.data('bs.scrollspy', (data = new ScrollSpy(this, options))) + if (typeof option == 'string') data[option]() + }) + } + + var old = $.fn.scrollspy + + $.fn.scrollspy = Plugin + $.fn.scrollspy.Constructor = ScrollSpy + + + // SCROLLSPY NO CONFLICT + // ===================== + + $.fn.scrollspy.noConflict = function () { + $.fn.scrollspy = old + return this + } + + + // SCROLLSPY DATA-API + // ================== + + $(window).on('load.bs.scrollspy.data-api', function () { + $('[data-spy="scroll"]').each(function () { + var $spy = $(this) + Plugin.call($spy, $spy.data()) + }) + }) + +}(jQuery); + +/* ======================================================================== + * Bootstrap: tab.js v3.3.6 + * http://getbootstrap.com/javascript/#tabs + * ======================================================================== + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // TAB CLASS DEFINITION + // ==================== + + var Tab = function (element) { + // jscs:disable requireDollarBeforejQueryAssignment + this.element = $(element) + // jscs:enable requireDollarBeforejQueryAssignment + } + + Tab.VERSION = '3.3.6' + + Tab.TRANSITION_DURATION = 150 + + Tab.prototype.show = function () { + var $this = this.element + var $ul = $this.closest('ul:not(.dropdown-menu)') + var selector = $this.data('target') + + if (!selector) { + selector = $this.attr('href') + selector = selector && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7 + } + + if ($this.parent('li').hasClass('active')) return + + var $previous = $ul.find('.active:last a') + var hideEvent = $.Event('hide.bs.tab', { + relatedTarget: $this[0] + }) + var showEvent = $.Event('show.bs.tab', { + relatedTarget: $previous[0] + }) + + $previous.trigger(hideEvent) + $this.trigger(showEvent) + + if (showEvent.isDefaultPrevented() || hideEvent.isDefaultPrevented()) return + + var $target = $(selector) + + this.activate($this.closest('li'), $ul) + this.activate($target, $target.parent(), function () { + $previous.trigger({ + type: 'hidden.bs.tab', + relatedTarget: $this[0] + }) + $this.trigger({ + type: 'shown.bs.tab', + relatedTarget: $previous[0] + }) + }) + } + + Tab.prototype.activate = function (element, container, callback) { + var $active = container.find('> .active') + var transition = callback + && $.support.transition + && ($active.length && $active.hasClass('fade') || !!container.find('> .fade').length) + + function next() { + $active + .removeClass('active') + .find('> .dropdown-menu > .active') + .removeClass('active') + .end() + .find('[data-toggle="tab"]') + .attr('aria-expanded', false) + + element + .addClass('active') + .find('[data-toggle="tab"]') + .attr('aria-expanded', true) + + if (transition) { + element[0].offsetWidth // reflow for transition + element.addClass('in') + } else { + element.removeClass('fade') + } + + if (element.parent('.dropdown-menu').length) { + element + .closest('li.dropdown') + .addClass('active') + .end() + .find('[data-toggle="tab"]') + .attr('aria-expanded', true) + } + + callback && callback() + } + + $active.length && transition ? + $active + .one('bsTransitionEnd', next) + .emulateTransitionEnd(Tab.TRANSITION_DURATION) : + next() + + $active.removeClass('in') + } + + + // TAB PLUGIN DEFINITION + // ===================== + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.tab') + + if (!data) $this.data('bs.tab', (data = new Tab(this))) + if (typeof option == 'string') data[option]() + }) + } + + var old = $.fn.tab + + $.fn.tab = Plugin + $.fn.tab.Constructor = Tab + + + // TAB NO CONFLICT + // =============== + + $.fn.tab.noConflict = function () { + $.fn.tab = old + return this + } + + + // TAB DATA-API + // ============ + + var clickHandler = function (e) { + e.preventDefault() + Plugin.call($(this), 'show') + } + + $(document) + .on('click.bs.tab.data-api', '[data-toggle="tab"]', clickHandler) + .on('click.bs.tab.data-api', '[data-toggle="pill"]', clickHandler) + +}(jQuery); + +/* ======================================================================== + * Bootstrap: affix.js v3.3.6 + * http://getbootstrap.com/javascript/#affix + * ======================================================================== + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // AFFIX CLASS DEFINITION + // ====================== + + var Affix = function (element, options) { + this.options = $.extend({}, Affix.DEFAULTS, options) + + this.$target = $(this.options.target) + .on('scroll.bs.affix.data-api', $.proxy(this.checkPosition, this)) + .on('click.bs.affix.data-api', $.proxy(this.checkPositionWithEventLoop, this)) + + this.$element = $(element) + this.affixed = null + this.unpin = null + this.pinnedOffset = null + + this.checkPosition() + } + + Affix.VERSION = '3.3.6' + + Affix.RESET = 'affix affix-top affix-bottom' + + Affix.DEFAULTS = { + offset: 0, + target: window + } + + Affix.prototype.getState = function (scrollHeight, height, offsetTop, offsetBottom) { + var scrollTop = this.$target.scrollTop() + var position = this.$element.offset() + var targetHeight = this.$target.height() + + if (offsetTop != null && this.affixed == 'top') return scrollTop < offsetTop ? 'top' : false + + if (this.affixed == 'bottom') { + if (offsetTop != null) return (scrollTop + this.unpin <= position.top) ? false : 'bottom' + return (scrollTop + targetHeight <= scrollHeight - offsetBottom) ? false : 'bottom' + } + + var initializing = this.affixed == null + var colliderTop = initializing ? scrollTop : position.top + var colliderHeight = initializing ? targetHeight : height + + if (offsetTop != null && scrollTop <= offsetTop) return 'top' + if (offsetBottom != null && (colliderTop + colliderHeight >= scrollHeight - offsetBottom)) return 'bottom' + + return false + } + + Affix.prototype.getPinnedOffset = function () { + if (this.pinnedOffset) return this.pinnedOffset + this.$element.removeClass(Affix.RESET).addClass('affix') + var scrollTop = this.$target.scrollTop() + var position = this.$element.offset() + return (this.pinnedOffset = position.top - scrollTop) + } + + Affix.prototype.checkPositionWithEventLoop = function () { + setTimeout($.proxy(this.checkPosition, this), 1) + } + + Affix.prototype.checkPosition = function () { + if (!this.$element.is(':visible')) return + + var height = this.$element.height() + var offset = this.options.offset + var offsetTop = offset.top + var offsetBottom = offset.bottom + var scrollHeight = Math.max($(document).height(), $(document.body).height()) + + if (typeof offset != 'object') offsetBottom = offsetTop = offset + if (typeof offsetTop == 'function') offsetTop = offset.top(this.$element) + if (typeof offsetBottom == 'function') offsetBottom = offset.bottom(this.$element) + + var affix = this.getState(scrollHeight, height, offsetTop, offsetBottom) + + if (this.affixed != affix) { + if (this.unpin != null) this.$element.css('top', '') + + var affixType = 'affix' + (affix ? '-' + affix : '') + var e = $.Event(affixType + '.bs.affix') + + this.$element.trigger(e) + + if (e.isDefaultPrevented()) return + + this.affixed = affix + this.unpin = affix == 'bottom' ? this.getPinnedOffset() : null + + this.$element + .removeClass(Affix.RESET) + .addClass(affixType) + .trigger(affixType.replace('affix', 'affixed') + '.bs.affix') + } + + if (affix == 'bottom') { + this.$element.offset({ + top: scrollHeight - height - offsetBottom + }) + } + } + + + // AFFIX PLUGIN DEFINITION + // ======================= + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.affix') + var options = typeof option == 'object' && option + + if (!data) $this.data('bs.affix', (data = new Affix(this, options))) + if (typeof option == 'string') data[option]() + }) + } + + var old = $.fn.affix + + $.fn.affix = Plugin + $.fn.affix.Constructor = Affix + + + // AFFIX NO CONFLICT + // ================= + + $.fn.affix.noConflict = function () { + $.fn.affix = old + return this + } + + + // AFFIX DATA-API + // ============== + + $(window).on('load', function () { + $('[data-spy="affix"]').each(function () { + var $spy = $(this) + var data = $spy.data() + + data.offset = data.offset || {} + + if (data.offsetBottom != null) data.offset.bottom = data.offsetBottom + if (data.offsetTop != null) data.offset.top = data.offsetTop + + Plugin.call($spy, data) + }) + }) + +}(jQuery); + + +/*! DataTables 1.10.11 + * ©2008-2015 SpryMedia Ltd - datatables.net/license + */ + +/** + * @summary DataTables + * @description Paginate, search and order HTML tables + * @version 1.10.11 + * @file jquery.dataTables.js + * @author SpryMedia Ltd (www.sprymedia.co.uk) + * @contact www.sprymedia.co.uk/contact + * @copyright Copyright 2008-2015 SpryMedia Ltd. + * + * This source file is free software, available under the following license: + * MIT license - http://datatables.net/license + * + * This source file is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the license files for details. + * + * For details please refer to: http://www.datatables.net + */ + +/*jslint evil: true, undef: true, browser: true */ +/*globals $,require,jQuery,define,_selector_run,_selector_opts,_selector_first,_selector_row_indexes,_ext,_Api,_api_register,_api_registerPlural,_re_new_lines,_re_html,_re_formatted_numeric,_re_escape_regex,_empty,_intVal,_numToDecimal,_isNumber,_isHtml,_htmlNumeric,_pluck,_pluck_order,_range,_stripHtml,_unique,_fnBuildAjax,_fnAjaxUpdate,_fnAjaxParameters,_fnAjaxUpdateDraw,_fnAjaxDataSrc,_fnAddColumn,_fnColumnOptions,_fnAdjustColumnSizing,_fnVisibleToColumnIndex,_fnColumnIndexToVisible,_fnVisbleColumns,_fnGetColumns,_fnColumnTypes,_fnApplyColumnDefs,_fnHungarianMap,_fnCamelToHungarian,_fnLanguageCompat,_fnBrowserDetect,_fnAddData,_fnAddTr,_fnNodeToDataIndex,_fnNodeToColumnIndex,_fnGetCellData,_fnSetCellData,_fnSplitObjNotation,_fnGetObjectDataFn,_fnSetObjectDataFn,_fnGetDataMaster,_fnClearTable,_fnDeleteIndex,_fnInvalidate,_fnGetRowElements,_fnCreateTr,_fnBuildHead,_fnDrawHead,_fnDraw,_fnReDraw,_fnAddOptionsHtml,_fnDetectHeader,_fnGetUniqueThs,_fnFeatureHtmlFilter,_fnFilterComplete,_fnFilterCustom,_fnFilterColumn,_fnFilter,_fnFilterCreateSearch,_fnEscapeRegex,_fnFilterData,_fnFeatureHtmlInfo,_fnUpdateInfo,_fnInfoMacros,_fnInitialise,_fnInitComplete,_fnLengthChange,_fnFeatureHtmlLength,_fnFeatureHtmlPaginate,_fnPageChange,_fnFeatureHtmlProcessing,_fnProcessingDisplay,_fnFeatureHtmlTable,_fnScrollDraw,_fnApplyToChildren,_fnCalculateColumnWidths,_fnThrottle,_fnConvertToWidth,_fnGetWidestNode,_fnGetMaxLenString,_fnStringToCss,_fnSortFlatten,_fnSort,_fnSortAria,_fnSortListener,_fnSortAttachListener,_fnSortingClasses,_fnSortData,_fnSaveState,_fnLoadState,_fnSettingsFromNode,_fnLog,_fnMap,_fnBindAction,_fnCallbackReg,_fnCallbackFire,_fnLengthOverflow,_fnRenderer,_fnDataSource,_fnRowAttributes*/ + +(function( factory ) { + "use strict"; + + if ( typeof define === 'function' && define.amd ) { + // AMD + define( ['jquery'], function ( $ ) { + return factory( $, window, document ); + } ); + } + else if ( typeof exports === 'object' ) { + // CommonJS + module.exports = function (root, $) { + if ( ! root ) { + // CommonJS environments without a window global must pass a + // root. This will give an error otherwise + root = window; + } + + if ( ! $ ) { + $ = typeof window !== 'undefined' ? // jQuery's factory checks for a global window + require('jquery') : + require('jquery')( root ); + } + + return factory( $, root, root.document ); + }; + } + else { + // Browser + factory( jQuery, window, document ); + } +} +(function( $, window, document, undefined ) { + "use strict"; + + /** + * DataTables is a plug-in for the jQuery Javascript library. It is a highly + * flexible tool, based upon the foundations of progressive enhancement, + * which will add advanced interaction controls to any HTML table. For a + * full list of features please refer to + * [DataTables.net](href="http://datatables.net). + * + * Note that the `DataTable` object is not a global variable but is aliased + * to `jQuery.fn.DataTable` and `jQuery.fn.dataTable` through which it may + * be accessed. + * + * @class + * @param {object} [init={}] Configuration object for DataTables. Options + * are defined by {@link DataTable.defaults} + * @requires jQuery 1.7+ + * + * @example + * // Basic initialisation + * $(document).ready( function { + * $('#example').dataTable(); + * } ); + * + * @example + * // Initialisation with configuration options - in this case, disable + * // pagination and sorting. + * $(document).ready( function { + * $('#example').dataTable( { + * "paginate": false, + * "sort": false + * } ); + * } ); + */ + var DataTable; + + + /* + * It is useful to have variables which are scoped locally so only the + * DataTables functions can access them and they don't leak into global space. + * At the same time these functions are often useful over multiple files in the + * core and API, so we list, or at least document, all variables which are used + * by DataTables as private variables here. This also ensures that there is no + * clashing of variable names and that they can easily referenced for reuse. + */ + + + // Defined else where + // _selector_run + // _selector_opts + // _selector_first + // _selector_row_indexes + + var _ext; // DataTable.ext + var _Api; // DataTable.Api + var _api_register; // DataTable.Api.register + var _api_registerPlural; // DataTable.Api.registerPlural + + var _re_dic = {}; + var _re_new_lines = /[\r\n]/g; + var _re_html = /<.*?>/g; + var _re_date_start = /^[\w\+\-]/; + var _re_date_end = /[\w\+\-]$/; + + // Escape regular expression special characters + var _re_escape_regex = new RegExp( '(\\' + [ '/', '.', '*', '+', '?', '|', '(', ')', '[', ']', '{', '}', '\\', '$', '^', '-' ].join('|\\') + ')', 'g' ); + + // http://en.wikipedia.org/wiki/Foreign_exchange_market + // - \u20BD - Russian ruble. + // - \u20a9 - South Korean Won + // - \u20BA - Turkish Lira + // - \u20B9 - Indian Rupee + // - R - Brazil (R$) and South Africa + // - fr - Swiss Franc + // - kr - Swedish krona, Norwegian krone and Danish krone + // - \u2009 is thin space and \u202F is narrow no-break space, both used in many + // standards as thousands separators. + var _re_formatted_numeric = /[',$£€¥%\u2009\u202F\u20BD\u20a9\u20BArfk]/gi; + + + var _empty = function ( d ) { + return !d || d === true || d === '-' ? true : false; + }; + + + var _intVal = function ( s ) { + var integer = parseInt( s, 10 ); + return !isNaN(integer) && isFinite(s) ? integer : null; + }; + + // Convert from a formatted number with characters other than `.` as the + // decimal place, to a Javascript number + var _numToDecimal = function ( num, decimalPoint ) { + // Cache created regular expressions for speed as this function is called often + if ( ! _re_dic[ decimalPoint ] ) { + _re_dic[ decimalPoint ] = new RegExp( _fnEscapeRegex( decimalPoint ), 'g' ); + } + return typeof num === 'string' && decimalPoint !== '.' ? + num.replace( /\./g, '' ).replace( _re_dic[ decimalPoint ], '.' ) : + num; + }; + + + var _isNumber = function ( d, decimalPoint, formatted ) { + var strType = typeof d === 'string'; + + // If empty return immediately so there must be a number if it is a + // formatted string (this stops the string "k", or "kr", etc being detected + // as a formatted number for currency + if ( _empty( d ) ) { + return true; + } + + if ( decimalPoint && strType ) { + d = _numToDecimal( d, decimalPoint ); + } + + if ( formatted && strType ) { + d = d.replace( _re_formatted_numeric, '' ); + } + + return !isNaN( parseFloat(d) ) && isFinite( d ); + }; + + + // A string without HTML in it can be considered to be HTML still + var _isHtml = function ( d ) { + return _empty( d ) || typeof d === 'string'; + }; + + + var _htmlNumeric = function ( d, decimalPoint, formatted ) { + if ( _empty( d ) ) { + return true; + } + + var html = _isHtml( d ); + return ! html ? + null : + _isNumber( _stripHtml( d ), decimalPoint, formatted ) ? + true : + null; + }; + + + var _pluck = function ( a, prop, prop2 ) { + var out = []; + var i=0, ien=a.length; + + // Could have the test in the loop for slightly smaller code, but speed + // is essential here + if ( prop2 !== undefined ) { + for ( ; i') + .css( { + position: 'fixed', + top: 0, + left: 0, + height: 1, + width: 1, + overflow: 'hidden' + } ) + .append( + $('
') + .css( { + position: 'absolute', + top: 1, + left: 1, + width: 100, + overflow: 'scroll' + } ) + .append( + $('
') + .css( { + width: '100%', + height: 10 + } ) + ) + ) + .appendTo( 'body' ); + + var outer = n.children(); + var inner = outer.children(); + + // Numbers below, in order, are: + // inner.offsetWidth, inner.clientWidth, outer.offsetWidth, outer.clientWidth + // + // IE6 XP: 100 100 100 83 + // IE7 Vista: 100 100 100 83 + // IE 8+ Windows: 83 83 100 83 + // Evergreen Windows: 83 83 100 83 + // Evergreen Mac with scrollbars: 85 85 100 85 + // Evergreen Mac without scrollbars: 100 100 100 100 + + // Get scrollbar width + browser.barWidth = outer[0].offsetWidth - outer[0].clientWidth; + + // IE6/7 will oversize a width 100% element inside a scrolling element, to + // include the width of the scrollbar, while other browsers ensure the inner + // element is contained without forcing scrolling + browser.bScrollOversize = inner[0].offsetWidth === 100 && outer[0].clientWidth !== 100; + + // In rtl text layout, some browsers (most, but not all) will place the + // scrollbar on the left, rather than the right. + browser.bScrollbarLeft = Math.round( inner.offset().left ) !== 1; + + // IE8- don't provide height and width for getBoundingClientRect + browser.bBounding = n[0].getBoundingClientRect().width ? true : false; + + n.remove(); + } + + $.extend( settings.oBrowser, DataTable.__browser ); + settings.oScroll.iBarWidth = DataTable.__browser.barWidth; + } + + + /** + * Array.prototype reduce[Right] method, used for browsers which don't support + * JS 1.6. Done this way to reduce code size, since we iterate either way + * @param {object} settings dataTables settings object + * @memberof DataTable#oApi + */ + function _fnReduce ( that, fn, init, start, end, inc ) + { + var + i = start, + value, + isSet = false; + + if ( init !== undefined ) { + value = init; + isSet = true; + } + + while ( i !== end ) { + if ( ! that.hasOwnProperty(i) ) { + continue; + } + + value = isSet ? + fn( value, that[i], i, that ) : + that[i]; + + isSet = true; + i += inc; + } + + return value; + } + + /** + * Add a column to the list used for the table with default values + * @param {object} oSettings dataTables settings object + * @param {node} nTh The th element for this column + * @memberof DataTable#oApi + */ + function _fnAddColumn( oSettings, nTh ) + { + // Add column to aoColumns array + var oDefaults = DataTable.defaults.column; + var iCol = oSettings.aoColumns.length; + var oCol = $.extend( {}, DataTable.models.oColumn, oDefaults, { + "nTh": nTh ? nTh : document.createElement('th'), + "sTitle": oDefaults.sTitle ? oDefaults.sTitle : nTh ? nTh.innerHTML : '', + "aDataSort": oDefaults.aDataSort ? oDefaults.aDataSort : [iCol], + "mData": oDefaults.mData ? oDefaults.mData : iCol, + idx: iCol + } ); + oSettings.aoColumns.push( oCol ); + + // Add search object for column specific search. Note that the `searchCols[ iCol ]` + // passed into extend can be undefined. This allows the user to give a default + // with only some of the parameters defined, and also not give a default + var searchCols = oSettings.aoPreSearchCols; + searchCols[ iCol ] = $.extend( {}, DataTable.models.oSearch, searchCols[ iCol ] ); + + // Use the default column options function to initialise classes etc + _fnColumnOptions( oSettings, iCol, $(nTh).data() ); + } + + + /** + * Apply options for a column + * @param {object} oSettings dataTables settings object + * @param {int} iCol column index to consider + * @param {object} oOptions object with sType, bVisible and bSearchable etc + * @memberof DataTable#oApi + */ + function _fnColumnOptions( oSettings, iCol, oOptions ) + { + var oCol = oSettings.aoColumns[ iCol ]; + var oClasses = oSettings.oClasses; + var th = $(oCol.nTh); + + // Try to get width information from the DOM. We can't get it from CSS + // as we'd need to parse the CSS stylesheet. `width` option can override + if ( ! oCol.sWidthOrig ) { + // Width attribute + oCol.sWidthOrig = th.attr('width') || null; + + // Style attribute + var t = (th.attr('style') || '').match(/width:\s*(\d+[pxem%]+)/); + if ( t ) { + oCol.sWidthOrig = t[1]; + } + } + + /* User specified column options */ + if ( oOptions !== undefined && oOptions !== null ) + { + // Backwards compatibility + _fnCompatCols( oOptions ); + + // Map camel case parameters to their Hungarian counterparts + _fnCamelToHungarian( DataTable.defaults.column, oOptions ); + + /* Backwards compatibility for mDataProp */ + if ( oOptions.mDataProp !== undefined && !oOptions.mData ) + { + oOptions.mData = oOptions.mDataProp; + } + + if ( oOptions.sType ) + { + oCol._sManualType = oOptions.sType; + } + + // `class` is a reserved word in Javascript, so we need to provide + // the ability to use a valid name for the camel case input + if ( oOptions.className && ! oOptions.sClass ) + { + oOptions.sClass = oOptions.className; + } + + $.extend( oCol, oOptions ); + _fnMap( oCol, oOptions, "sWidth", "sWidthOrig" ); + + /* iDataSort to be applied (backwards compatibility), but aDataSort will take + * priority if defined + */ + if ( oOptions.iDataSort !== undefined ) + { + oCol.aDataSort = [ oOptions.iDataSort ]; + } + _fnMap( oCol, oOptions, "aDataSort" ); + } + + /* Cache the data get and set functions for speed */ + var mDataSrc = oCol.mData; + var mData = _fnGetObjectDataFn( mDataSrc ); + var mRender = oCol.mRender ? _fnGetObjectDataFn( oCol.mRender ) : null; + + var attrTest = function( src ) { + return typeof src === 'string' && src.indexOf('@') !== -1; + }; + oCol._bAttrSrc = $.isPlainObject( mDataSrc ) && ( + attrTest(mDataSrc.sort) || attrTest(mDataSrc.type) || attrTest(mDataSrc.filter) + ); + oCol._setter = null; + + oCol.fnGetData = function (rowData, type, meta) { + var innerData = mData( rowData, type, undefined, meta ); + + return mRender && type ? + mRender( innerData, type, rowData, meta ) : + innerData; + }; + oCol.fnSetData = function ( rowData, val, meta ) { + return _fnSetObjectDataFn( mDataSrc )( rowData, val, meta ); + }; + + // Indicate if DataTables should read DOM data as an object or array + // Used in _fnGetRowElements + if ( typeof mDataSrc !== 'number' ) { + oSettings._rowReadObject = true; + } + + /* Feature sorting overrides column specific when off */ + if ( !oSettings.oFeatures.bSort ) + { + oCol.bSortable = false; + th.addClass( oClasses.sSortableNone ); // Have to add class here as order event isn't called + } + + /* Check that the class assignment is correct for sorting */ + var bAsc = $.inArray('asc', oCol.asSorting) !== -1; + var bDesc = $.inArray('desc', oCol.asSorting) !== -1; + if ( !oCol.bSortable || (!bAsc && !bDesc) ) + { + oCol.sSortingClass = oClasses.sSortableNone; + oCol.sSortingClassJUI = ""; + } + else if ( bAsc && !bDesc ) + { + oCol.sSortingClass = oClasses.sSortableAsc; + oCol.sSortingClassJUI = oClasses.sSortJUIAscAllowed; + } + else if ( !bAsc && bDesc ) + { + oCol.sSortingClass = oClasses.sSortableDesc; + oCol.sSortingClassJUI = oClasses.sSortJUIDescAllowed; + } + else + { + oCol.sSortingClass = oClasses.sSortable; + oCol.sSortingClassJUI = oClasses.sSortJUI; + } + } + + + /** + * Adjust the table column widths for new data. Note: you would probably want to + * do a redraw after calling this function! + * @param {object} settings dataTables settings object + * @memberof DataTable#oApi + */ + function _fnAdjustColumnSizing ( settings ) + { + /* Not interested in doing column width calculation if auto-width is disabled */ + if ( settings.oFeatures.bAutoWidth !== false ) + { + var columns = settings.aoColumns; + + _fnCalculateColumnWidths( settings ); + for ( var i=0 , iLen=columns.length ; i